<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Martin Oehlert</title>
    <description>The latest articles on DEV Community by Martin Oehlert (@martin_oehlert).</description>
    <link>https://dev.to/martin_oehlert</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1661015%2Fd0bdf508-0244-49d8-8655-aea054d71b86.png</url>
      <title>DEV Community: Martin Oehlert</title>
      <link>https://dev.to/martin_oehlert</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/martin_oehlert"/>
    <language>en</language>
    <item>
      <title>.NET Aspire: Orchestrating Cloud-Native Apps</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 04 Sep 2026 05:48:18 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/net-aspire-orchestrating-cloud-native-apps-15f3</link>
      <guid>https://dev.to/martin_oehlert/net-aspire-orchestrating-cloud-native-apps-15f3</guid>
      <description>&lt;p&gt;Part 3 started two services and two sidecars from a Dapr multi-app run file, one &lt;code&gt;dapr run&lt;/code&gt; per app, and that file never leaves your laptop. Aspire replaces it with a C# program that also never leaves your laptop: aspire.dev states that "The AppHost isn't a production runtime. It's a development-time orchestration tool that simplifies the process of running and debugging your application locally." Both artifacts are deleted at the deployment boundary. So the question is what the C# one does in the hours before that boundary that the run file does not. The four projects that answer it are in &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/DaprAspireDemo" rel="noopener noreferrer"&gt;DaprAspireDemo in azure-functions-samples&lt;/a&gt;: an AppHost, a ServiceDefaults library, and the two services, building clean at 0 warnings and 0 errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the AppHost actually owns
&lt;/h2&gt;

&lt;p&gt;The local loop is still one command, and that command is the only line of the developer workflow that changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dapr run &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;    &lt;span class="c"&gt;# Part 3: one run file, one dapr run per app&lt;/span&gt;
aspire run       &lt;span class="c"&gt;# Part 4: one C# program, the same four processes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second one came back with four resources in the dashboard, all &lt;code&gt;Running&lt;/code&gt;. Two are the services, listed against their &lt;code&gt;.csproj&lt;/code&gt;. The other two are &lt;code&gt;order-service-dapr-cli&lt;/code&gt; and &lt;code&gt;inventory-service-dapr-cli&lt;/code&gt;, executable resources whose Source column holds the entire &lt;code&gt;dapr run&lt;/code&gt; command line the integration assembled: the app ID, the app port, the three sidecar ports, the app channel address, the app protocol, and whichever components flag it chose to pass. That column is the best debugging surface in this whole setup. "Which component folder did this sidecar actually load" becomes one row in a browser instead of a log grep, and the in-memory state store further down is nothing but an exercise in reading it.&lt;/p&gt;

&lt;p&gt;The run file had no equivalent. &lt;code&gt;dapr run -f .&lt;/code&gt; gives you four processes interleaving their stdout into one terminal and a &lt;code&gt;dapr stop -f .&lt;/code&gt; to take the set back down. Under the AppHost each sidecar is a resource in its own right: its own console log pane, its own recorded start time, its own Stop and Restart actions, and its own OTLP stream arriving in the same dashboard as the app it sits beside. The sidecar stops being something you remember to start and becomes something you can point at.&lt;/p&gt;

&lt;p&gt;Startup order is the second thing the AppHost owns, and it is where people arriving from Docker Compose lose an afternoon. Two methods look interchangeable and are not. &lt;code&gt;WithReference(x)&lt;/code&gt; is wiring: it injects the configuration a consumer needs in order to find &lt;code&gt;x&lt;/code&gt;, and the documentation is explicit that it says nothing whatsoever about who starts first. &lt;code&gt;WaitFor(x)&lt;/code&gt; is ordering: it holds a resource back until &lt;code&gt;x&lt;/code&gt; is running and its registered health checks report healthy. Read &lt;code&gt;WithReference&lt;/code&gt; as compose's &lt;code&gt;depends_on&lt;/code&gt; and you have written down a dependency the runtime will not honour, which surfaces as a startup race rather than as an error message. &lt;a href="https://aspire.dev/app-host/migrate-from-docker-compose/" rel="noopener noreferrer"&gt;aspire.dev's own compose migration guide&lt;/a&gt; names this as the top gotcha for people coming from compose, and it earns the title.&lt;/p&gt;

&lt;p&gt;One piece of housekeeping before any of this reproduces on your machine. Ask a box set up today which Aspire version it is on and you get three answers: one from the CLI, one from the installed project templates, and one from the packages that actually resolve at build time. &lt;code&gt;aspire update&lt;/code&gt; reconciles them. Everything below is pinned to &lt;code&gt;Aspire.AppHost.Sdk&lt;/code&gt; 13.5.3 and &lt;code&gt;CommunityToolkit.Aspire.Hosting.Dapr&lt;/code&gt; 13.0.0, and version-qualifying your own transcripts is worth the ten seconds when a stack moves this fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  It is not docker-compose, and it does not replace it
&lt;/h3&gt;

&lt;p&gt;The reflex on first reading an &lt;code&gt;AppHost.cs&lt;/code&gt; is that this is a compose file with C# syntax, and half of that reflex is correct. The compose file that exists purely to stand up Postgres, Redis, RabbitMQ or Azurite and then run your services does go away: &lt;code&gt;AddPostgres&lt;/code&gt; plus &lt;code&gt;AddProject&lt;/code&gt; covers it, and adds service discovery, health-gated startup and the dashboard on top. The part that goes away with it is the workaround where you containerise a .NET service you have no intention of shipping in a container, just so compose can see it. &lt;code&gt;AddProject&lt;/code&gt; runs it as a host process, so breakpoints and the ordinary build loop keep working.&lt;/p&gt;

&lt;p&gt;The other half of the reflex is wrong in a way that matters, because a compose file is a deployment artifact and the AppHost is not. Aspire does not delete your compose file. It writes one: publishing to a Docker Compose environment emits &lt;code&gt;docker-compose.yaml&lt;/code&gt;, a &lt;code&gt;.env&lt;/code&gt;, per-environment &lt;code&gt;.env&lt;/code&gt; files, and a &lt;code&gt;Dockerfile&lt;/code&gt; per resource, and &lt;code&gt;aspire deploy&lt;/code&gt; then runs &lt;code&gt;docker compose up -d --remove-orphans&lt;/code&gt; over the result. The C# is the model; the YAML is the output.&lt;/p&gt;

&lt;p&gt;Three gaps matter before you delete anything, and all three come from that same migration guide rather than from community complaints. &lt;code&gt;deploy.resources.limits.memory&lt;/code&gt; and &lt;code&gt;cpus&lt;/code&gt; are documented as "Not supported", so you cannot reproduce a memory-starved container locally the way compose lets you. Restart policies exist at publish time only. And network isolation has no direct equivalent at all: Microsoft's own wording is that "If your Docker Compose setup relies on network isolation (for example, preventing a frontend service from directly accessing the database), Aspire doesn't provide a direct equivalent." That last one is the one to read twice if your compose file uses a private network as a dev-time security boundary, because the boundary does not survive the move and nothing tells you it is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  One file that starts everything
&lt;/h2&gt;

&lt;p&gt;The AppHost is a console application whose whole job is to describe the other four resources. Its project file comes first, because the shape of it changed on the 13.x line and the version most tutorials show no longer matches what the template writes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Project&lt;/span&gt; &lt;span class="na"&gt;Sdk=&lt;/span&gt;&lt;span class="s"&gt;"Aspire.AppHost.Sdk/13.5.3"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;PropertyGroup&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;OutputType&amp;gt;&lt;/span&gt;Exe&lt;span class="nt"&gt;&amp;lt;/OutputType&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;IsAspireHost&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/IsAspireHost&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;AspireUseCliBundle&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/AspireUseCliBundle&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;UserSecretsId&amp;gt;&lt;/span&gt;dapraspiredemo-apphost-w36&lt;span class="nt"&gt;&amp;lt;/UserSecretsId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;ItemGroup&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"CommunityToolkit.Aspire.Hosting.Dapr"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/ItemGroup&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;ItemGroup&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;ProjectReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"..\DaprAspireDemo.OrderService\DaprAspireDemo.OrderService.csproj"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;ProjectReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"..\DaprAspireDemo.InventoryService\DaprAspireDemo.InventoryService.csproj"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/ItemGroup&amp;gt;&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The Aspire version lives on the &lt;code&gt;Sdk&lt;/code&gt; attribute of the &lt;code&gt;Project&lt;/code&gt; element, and there is no &lt;code&gt;Aspire.Hosting.AppHost&lt;/code&gt; package reference anywhere to match it: the only &lt;code&gt;PackageReference&lt;/code&gt; in the file is the Dapr integration. The entry point is &lt;code&gt;AppHost.cs&lt;/code&gt;, not &lt;code&gt;Program.cs&lt;/code&gt;. Material written against Aspire 9.x describes a different file, with a nested &lt;code&gt;&amp;lt;Sdk Name="Aspire.AppHost.Sdk" Version="..." /&amp;gt;&lt;/code&gt; element and a hosting package reference, so a &lt;code&gt;PropertyGroup&lt;/code&gt; copied from a 9.x post lands in a project that no longer expects it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;AspireUseCliBundle&amp;gt;&lt;/code&gt; is the line worth stopping on. Setting it to true takes the dashboard and the orchestrator (DCP) from the installed Aspire CLI bundle rather than from NuGet, which is also what keeps &lt;code&gt;Aspire.Dashboard.Sdk&lt;/code&gt; and the DCP packages out of the restore graph, and it needs the &lt;code&gt;aspire&lt;/code&gt; CLI on &lt;code&gt;PATH&lt;/code&gt;. Build the project on 13.5 without it and MSBuild answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;warning ASPIRE010: DaprAspireDemo.AppHost is configured with AspireUseCliBundle=false. Some Aspire
features require the Aspire CLI bundle. Set AspireUseCliBundle=true to enable those features, or
suppress ASPIRE010 to continue without the bundle. See https://aka.ms/aspire/diagnostics/aspire010
for more information.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Aspire.Hosting.AppHost.props&lt;/code&gt; defaults the property to &lt;code&gt;false&lt;/code&gt; and &lt;code&gt;Aspire.Hosting.AppHost.targets&lt;/code&gt; then warns that it is false, which is an odd pairing until you reach the detail that makes it dangerous: &lt;code&gt;TreatWarningsAsErrors=true&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; promote ASPIRE010. It comes out of an MSBuild &lt;code&gt;&amp;lt;Warning&amp;gt;&lt;/code&gt; task rather than out of a Roslyn diagnostic, so a CI gate that turns warnings into errors passes it straight through. Only a rule that counts warnings catches it, which means a repository with a strict-looking gate can ship an AppHost missing whatever the bundle provides and never see a red build. The property is new in 13.5; it did not exist in 13.3.5, so an older sample carries no clue that it is missing.&lt;/p&gt;

&lt;p&gt;The one package reference is the one to get right, and the obvious search result is the wrong package. &lt;code&gt;Aspire.Hosting.Dapr&lt;/code&gt; is dead: it stopped at 9.1.0, and every version of it is &lt;a href="https://www.nuget.org/packages/Aspire.Hosting.Dapr" rel="noopener noreferrer"&gt;deprecated on nuget.org&lt;/a&gt; with the notice &lt;em&gt;"We will no longer be publishing new versions of this package. We recommend using the CommunityToolkit.Aspire.Hosting.Dapr package going forward."&lt;/em&gt; "Deprecated" reads as "abandoned", and that is not what happened here. The move was an ownership transfer, argued out in the comments of &lt;a href="https://github.com/CommunityToolkit/Aspire/issues/349" rel="noopener noreferrer"&gt;CommunityToolkit/Aspire#349&lt;/a&gt;: David Fowler laid out three options, keep it in core, move it to the Toolkit, or hand it to the Dapr .NET client team, and the thread settled on the Toolkit. The reason on the record is maintainer bandwidth, that Dapr was not a priority for the core team and every PR would still need core review, not a technical fault in the package. The confusing result is that &lt;a href="https://aspire.dev/integrations/frameworks/dapr/dapr-host/" rel="noopener noreferrer"&gt;the Dapr integration docs are first-party on aspire.dev&lt;/a&gt; while the code is community-maintained, so the page you read and the package you install do not share a name. &lt;code&gt;CommunityToolkit.Aspire.Hosting.Dapr&lt;/code&gt; 13.0.0 is the newest stable and depends on &lt;code&gt;Aspire.Hosting &amp;gt;= 13.0.0&lt;/code&gt;, so it rides the same 13.x train as the AppHost SDK above.&lt;/p&gt;

&lt;p&gt;That is the whole project file. Here is the program it builds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;CommunityToolkit.Aspire.Hosting.Dapr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DistributedApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDapr&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;stateStore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDaprStateStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"statestore"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;DaprComponentOptions&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;LocalPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Combine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AppHostDirectory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;".."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"components"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"statestore.yaml"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;inventory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DaprAspireDemo_InventoryService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"inventory-service"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithDaprSidecar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sidecar&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sidecar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;DaprSidecarOptions&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;AppId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"inventory-service"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DaprAspireDemo_OrderService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"order-service"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithDaprSidecar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sidecar&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sidecar&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;DaprSidecarOptions&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;AppId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"order-service"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stateStore&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv7udi3y6r2w0a3sd8o5l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv7udi3y6r2w0a3sd8o5l.png" alt="Four resources under one AppHost: two services, two Dapr sidecars, one component file" width="800" height="1009"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;builder.AddDapr()&lt;/code&gt; is optional, which is the kind of claim worth distrusting, so I commented the line out and ran it again: both &lt;code&gt;*-dapr-cli&lt;/code&gt; resources still started, &lt;code&gt;POST /orders&lt;/code&gt; still returned 201, and the &lt;code&gt;dapr run&lt;/code&gt; command line the integration assembled came back character-identical modulo ports. &lt;code&gt;WithDaprSidecar()&lt;/code&gt; calls &lt;code&gt;builder.ApplicationBuilder.AddDapr()&lt;/code&gt; itself, and &lt;code&gt;AddDapr&lt;/code&gt; registers its lifecycle hook through &lt;code&gt;TryAddEventingSubscriber&lt;/code&gt;, so the second call is a no-op. What writing the line buys you is the &lt;code&gt;AddDapr(Action&amp;lt;DaprOptions&amp;gt;)&lt;/code&gt; overload, and &lt;code&gt;DaprOptions&lt;/code&gt; has exactly three members: &lt;code&gt;DaprPath&lt;/code&gt; for a Dapr CLI that is not on &lt;code&gt;PATH&lt;/code&gt;, &lt;code&gt;EnableTelemetry&lt;/code&gt; for turning the sidecars' dashboard telemetry off, and &lt;code&gt;PublishingConfigurationAction&lt;/code&gt; for the publish step. Called with no callback, as here, it is the line that names the dependency: documentation rather than wiring.&lt;/p&gt;

&lt;p&gt;The lifecycle hook it registers does not check whether Dapr is initialised. It probes for the &lt;code&gt;dapr&lt;/code&gt; CLI binary, Homebrew prefix included, so a machine where somebody ran &lt;code&gt;brew install dapr&lt;/code&gt; and stopped there passes the check, the AppHost starts clean, and the &lt;code&gt;order-service-dapr-cli&lt;/code&gt; resource fails on its own some seconds later because &lt;code&gt;dapr run&lt;/code&gt; has no &lt;code&gt;daprd&lt;/code&gt; to launch. The tell is &lt;code&gt;dapr --version&lt;/code&gt; answering with a CLI version and a runtime version of &lt;code&gt;n/a&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AddDaprStateStore&lt;/code&gt; gets the second aside below, because the argument that looks optional is the one that decides whether your data outlives a process. The name is doing three jobs in the meantime: &lt;code&gt;statestore&lt;/code&gt; is the Aspire resource name, the &lt;code&gt;metadata.name&lt;/code&gt; in the component YAML, and the first argument &lt;code&gt;order-service&lt;/code&gt; passes to &lt;code&gt;SaveStateAsync&lt;/code&gt;. All three have to be the same string, and nothing checks that they are.&lt;/p&gt;

&lt;p&gt;Both &lt;code&gt;WithDaprSidecar&lt;/code&gt; calls take a callback rather than an options object, and the callback receives a sidecar builder carrying its own &lt;code&gt;WithOptions&lt;/code&gt; and &lt;code&gt;WithReference&lt;/code&gt;. &lt;code&gt;DaprSidecarOptions&lt;/code&gt; has 32 properties; this file sets one of them, twice. What the integration fills in when you set nothing is the part to know before you start overriding things: &lt;code&gt;--app-id&lt;/code&gt; falls back to the Aspire resource name, &lt;code&gt;--app-port&lt;/code&gt; to the HTTP endpoint Aspire allocated for the app, and &lt;code&gt;--config&lt;/code&gt; is not passed at all. The two &lt;code&gt;AppId&lt;/code&gt; values above are the fallback spelled out, which is what stops a later rename of the Aspire resource from silently breaking every caller. The Source column shows you the resulting &lt;code&gt;dapr run&lt;/code&gt; command line without a debugger.&lt;/p&gt;

&lt;p&gt;One number in that command line will not match the one your application sees. On the run behind this article, &lt;code&gt;order-service&lt;/code&gt; was handed &lt;code&gt;DAPR_HTTP_PORT=57679&lt;/code&gt; while its own daprd started with &lt;code&gt;--dapr-http-port 57685&lt;/code&gt;. DCP puts a proxy in front of the sidecar, so the port the app talks to is not the port daprd binds. Both answer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;orders.WaitFor(inventory)&lt;/code&gt; is the last line and it is not a Dapr feature at all. Part 3 dealt with the same window from inside the caller: when the sidecar cannot route to an app ID it answers HTTP 500 with an &lt;code&gt;ERR_DIRECT_INVOKE&lt;/code&gt; body, and &lt;code&gt;InventoryClient&lt;/code&gt; translated that into a retryable failure rather than a flat 502. &lt;code&gt;WaitFor&lt;/code&gt; addresses it from the other end, by refusing to start the caller until the callee reports healthy. They are not alternatives. The ordering is there so the startup case cannot arise; the error translation stays because the target can also fall over at three in the morning, when nothing is starting up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three ways to write that reference, one that compiles
&lt;/h3&gt;

&lt;p&gt;The form printed in the package's own README does not build in this repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DaprAspireDemo_OrderService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"order-service"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithDaprSidecar&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stateStore&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;- [Obsolete]; fails the build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AppHost.cs(10,1): error CS0618: 'IDistributedApplicationResourceBuilderExtensions.WithReference&amp;lt;TDestination&amp;gt;(IResourceBuilder&amp;lt;TDestination&amp;gt;, IResourceBuilder&amp;lt;IDaprComponentResource&amp;gt;)' is obsolete: 'Add reference to the sidecar resource instead of the project resource'

Build FAILED.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error is harder to act on than it looks. The position is &lt;code&gt;(10,1)&lt;/code&gt;, the start of the whole chained expression, not the &lt;code&gt;.WithReference&lt;/code&gt; token three lines further down, so an editor puts the squiggle on &lt;code&gt;builder.AddProject&lt;/code&gt; and the method it is complaining about is off the highlighted line entirely. And the parameterless &lt;code&gt;.WithDaprSidecar()&lt;/code&gt; in the middle is fine: it carries no diagnostic of its own, which sends you looking at the wrong call first. Only &lt;code&gt;WithReference&lt;/code&gt; on the &lt;em&gt;project&lt;/em&gt; builder is obsolete.&lt;/p&gt;

&lt;p&gt;Under &lt;code&gt;TreatWarningsAsErrors&lt;/code&gt; this fails the build, which is the good outcome. Without that setting it is a warning you can live with for months, and it is not cosmetic. Published both ways, the obsolete form emits a &lt;code&gt;dapr.v0&lt;/code&gt; resource with no &lt;code&gt;components&lt;/code&gt; array at all, while the sidecar-callback form emits &lt;code&gt;"components": ["statestore", "pubsub"]&lt;/code&gt;. Same C# intent, different published output, and what that difference costs you comes back at the end, when this model meets &lt;code&gt;aspire publish&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two other spellings look plausible and are not. &lt;code&gt;builder.AddDaprSidecar("order-service")&lt;/code&gt; appears in enough posts to feel like an API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AppHost.cs(5,9): error CS1061: 'IDistributedApplicationBuilder' does not contain a definition for 'AddDaprSidecar' and no accessible extension method 'AddDaprSidecar' accepting a first argument of type 'IDistributedApplicationBuilder' could be found (are you missing a using directive or an assembly reference?)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading the assembly explains why: the builder-level Dapr surface is exactly four methods, &lt;code&gt;AddDapr&lt;/code&gt;, &lt;code&gt;AddDaprComponent&lt;/code&gt;, &lt;code&gt;AddDaprPubSub&lt;/code&gt; and &lt;code&gt;AddDaprStateStore&lt;/code&gt;, and &lt;code&gt;AddDaprSidecar&lt;/code&gt; is not among them. A sidecar attaches to a resource; it is never declared standalone.&lt;/p&gt;

&lt;p&gt;The third one is the &lt;code&gt;using&lt;/code&gt; at the top of &lt;code&gt;AppHost.cs&lt;/code&gt;, which is easy to read as decoration and is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AppHost.cs(11,26): error CS0246: The type or namespace name 'DaprSidecarOptions' could not be found (are you missing a using directive or an assembly reference?)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The extension methods are declared in the &lt;code&gt;Aspire.Hosting&lt;/code&gt; namespace, which the AppHost's implicit usings already bring in, so &lt;code&gt;AddDapr&lt;/code&gt; and &lt;code&gt;WithDaprSidecar&lt;/code&gt; resolve with no using directive at all. &lt;code&gt;DaprSidecarOptions&lt;/code&gt; and &lt;code&gt;DaprComponentOptions&lt;/code&gt; are not in that namespace. Write the callback form without &lt;code&gt;using CommunityToolkit.Aspire.Hosting.Dapr;&lt;/code&gt; and the calls compile while the options types do not, which is exactly the kind of half-working state that sends you back to the package reference. The sample on aspire.dev omits the using.&lt;/p&gt;

&lt;p&gt;What compiles, and what the file above already uses, is the reference on the sidecar builder: &lt;code&gt;.WithDaprSidecar(sidecar =&amp;gt; sidecar.WithOptions(...).WithReference(stateStore))&lt;/code&gt;. One callback, everything Dapr-shaped inside it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The state store you did not write is in memory
&lt;/h3&gt;

&lt;p&gt;Delete the &lt;code&gt;LocalPath&lt;/code&gt; argument from &lt;code&gt;AddDaprStateStore&lt;/code&gt; and everything keeps working. That is the problem.&lt;/p&gt;

&lt;p&gt;The integration does not go looking for a component you might already have. It writes its own, into a temp directory, and this is what it writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dapr.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Component&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;statestore&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state.in-memory&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
  &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it passes that directory to the sidecar as &lt;code&gt;--resources-path&lt;/code&gt;. The mechanism is in that flag, and it is the part the advice you will find online gets backwards: &lt;code&gt;--resources-path&lt;/code&gt; &lt;strong&gt;replaces&lt;/strong&gt; &lt;code&gt;--components-path&lt;/code&gt;, it does not add to it. Whatever is in the machine's &lt;code&gt;~/.dapr/components&lt;/code&gt;, including the Redis state store &lt;code&gt;dapr init&lt;/code&gt; put there, is invisible to a sidecar started that way.&lt;/p&gt;

&lt;p&gt;The proof is an asymmetry inside a single launch. Two daprd processes, one &lt;code&gt;ps&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;daprd ... --components-path /Users/martino/.dapr/components  --app-id inventory-service
daprd ... --resources-path /var/folders/.../T/aspire-dapr.2X7RwV/statestore --app-id order-service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inventory-service&lt;/code&gt; has no &lt;code&gt;WithReference&lt;/code&gt;, so it keeps the default &lt;code&gt;--components-path&lt;/code&gt; and sees the machine's real components: its &lt;code&gt;/v1.0/metadata&lt;/code&gt; returns two, &lt;code&gt;pubsub&lt;/code&gt; on &lt;code&gt;pubsub.redis&lt;/code&gt; and &lt;code&gt;statestore&lt;/code&gt; on &lt;code&gt;state.redis&lt;/code&gt;. &lt;code&gt;order-service&lt;/code&gt;, the one service in the solution that actually stores anything, has the reference, gets &lt;code&gt;--resources-path&lt;/code&gt;, and returns exactly one component: &lt;code&gt;statestore&lt;/code&gt; on &lt;code&gt;state.in-memory&lt;/code&gt;. The service that needs a database is the service that lost it.&lt;/p&gt;

&lt;p&gt;Nothing about this announces itself, because the generated store is fully functional. &lt;code&gt;SaveStateAsync&lt;/code&gt; succeeds, &lt;code&gt;GetStateAsync&lt;/code&gt; hands the value back, the dashboard trace has the same shape either way, and no log line anywhere mentions that the store is in memory. It fails on one event only: the sidecar restarting. Which is to say never on a laptop, and eventually in production.&lt;/p&gt;

&lt;p&gt;Pointing &lt;code&gt;LocalPath&lt;/code&gt; at the component file in the repository is the fix, and Redis can be asked whether it took:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;dapr_redis redis-cli KEYS &lt;span class="s1"&gt;'*'&lt;/span&gt;
order-service||ORD-2001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;LocalPath&lt;/code&gt; set, &lt;code&gt;/v1.0/metadata&lt;/code&gt; on the order sidecar reports &lt;code&gt;state.redis&lt;/code&gt; and that key appears after a write; without it, &lt;code&gt;KEYS '*'&lt;/code&gt; comes back empty and the metadata says &lt;code&gt;state.in-memory&lt;/code&gt;. The key is also Part 3's point arriving intact: the application passed &lt;code&gt;ORD-2001&lt;/code&gt;, and &lt;code&gt;order-service||&lt;/code&gt; in front of it was added by the sidecar, not by the SDK, so the app ID is still part of the physical key even though nothing in the AppHost or the service ever wrote it there.&lt;/p&gt;

&lt;p&gt;Check it before you trust it. The sidecar will tell you what it loaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:&amp;lt;daprHttpPort&amp;gt;/v1.0/metadata | jq &lt;span class="s1"&gt;'.components'&lt;/span&gt;
&lt;span class="c"&gt;# [{"name":"statestore","type":"state.in-memory","version":"v1", ...}]   &amp;lt;- the bug, visible&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;daprHttpPort&lt;/code&gt; is the one on the &lt;code&gt;dapr run&lt;/code&gt; line in the dashboard, not the one in the app's &lt;code&gt;DAPR_HTTP_PORT&lt;/code&gt;, though as noted above both answer. If you would rather not leave the browser, the same dashboard Source column answers the question without a request: look at which of &lt;code&gt;--resources-path&lt;/code&gt; and &lt;code&gt;--components-path&lt;/code&gt; the sidecar was given.&lt;/p&gt;

&lt;h2&gt;
  
  
  ServiceDefaults: what every service inherits
&lt;/h2&gt;

&lt;p&gt;Both services start with the same line, and that line is the entire footprint Aspire has inside a service process. All four calls it makes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;TBuilder&lt;/span&gt; &lt;span class="n"&gt;AddServiceDefaults&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="n"&gt;TBuilder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;TBuilder&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IHostApplicationBuilder&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDefaultHealthChecks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddServiceDiscovery&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureHttpClientDefaults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Turn on resilience by default&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddStandardResilienceHandler&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Turn on service discovery by default&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddServiceDiscovery&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in this sample was customised. &lt;code&gt;Extensions.cs&lt;/code&gt; is byte-identical between the 13.3.5 and 13.5.3 templates and byte-identical again to the copy sitting in the repository's older &lt;code&gt;AspireDemo&lt;/code&gt; solution, so the file above is current for the whole 13.x line and you can read someone else's copy as if it were your own.&lt;/p&gt;

&lt;p&gt;It can stay identical because there is nothing to version. No &lt;code&gt;Aspire.ServiceDefaults&lt;/code&gt; package exists to install. The template stamps a class library into your solution and from then on the code is yours, which is why you read the four calls above rather than trusting them. Look at what the project file references and the ownership gets clearer: &lt;code&gt;Microsoft.Extensions.Http.Resilience&lt;/code&gt;, &lt;code&gt;Microsoft.Extensions.ServiceDiscovery&lt;/code&gt;, and five &lt;code&gt;OpenTelemetry.*&lt;/code&gt; packages. Not one &lt;code&gt;Aspire.*&lt;/code&gt; reference anywhere. Resilience and service discovery ship out of &lt;code&gt;dotnet/extensions&lt;/code&gt; on the 10.x line while the AppHost next door is pinned to 13.5.3, so upgrading Aspire does not upgrade this, and the three-way version skew from earlier does not reach it. &lt;a href="https://aspire.dev/get-started/csharp-service-defaults/" rel="noopener noreferrer"&gt;First-party guidance&lt;/a&gt; is firm about keeping it that way: &lt;em&gt;"Don't include other shared functionality or models in this project."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One call is missing from the list, and it is the one people assume is there. &lt;code&gt;MapDefaultEndpoints&lt;/code&gt; is not invoked by &lt;code&gt;AddServiceDefaults&lt;/code&gt;; you write it yourself in &lt;code&gt;Program.cs&lt;/code&gt;. The reason is a type mismatch rather than an oversight. Everything inside &lt;code&gt;AddServiceDefaults&lt;/code&gt; is generic over &lt;code&gt;TBuilder : IHostApplicationBuilder&lt;/code&gt;, so it works on a worker host or a MAUI builder as happily as on a web app, while &lt;code&gt;MapDefaultEndpoints&lt;/code&gt; takes a concrete &lt;code&gt;WebApplication&lt;/code&gt;. A background worker can register health checks and has nothing to map them on, so registration and mapping had to be two calls. The aside below is about what those endpoints do once you have mapped them, which is less than the name suggests.&lt;/p&gt;

&lt;p&gt;The interesting default is one method deeper than the list above, in the private &lt;code&gt;AddOpenTelemetryExporters&lt;/code&gt; that &lt;code&gt;ConfigureOpenTelemetry&lt;/code&gt; calls on its last line, and it is a conditional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;useOtlpExporter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"OTEL_EXPORTER_OTLP_ENDPOINT"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;useOtlpExporter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;UseOtlpExporter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything &lt;code&gt;ConfigureOpenTelemetry&lt;/code&gt; does before reaching that call is unconditional: logging with formatted messages and scopes, ASP.NET Core, HttpClient and runtime metrics, ASP.NET Core and HttpClient tracing. The exporter is the only part that asks a question first, and the question is whether &lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/code&gt; has a value. If it does not, no exporter is registered at all. The instrumentation still runs, still allocates, still builds every span, and then drops the lot. Nothing warns, because from the SDK's point of view nothing is wrong.&lt;/p&gt;

&lt;p&gt;Under the AppHost that variable is always set, along with &lt;code&gt;OTEL_EXPORTER_OTLP_PROTOCOL&lt;/code&gt;, &lt;code&gt;OTEL_SERVICE_NAME&lt;/code&gt; (literally the string you passed to &lt;code&gt;AddProject&lt;/code&gt;), a per-run &lt;code&gt;service.instance.id&lt;/code&gt; in &lt;code&gt;OTEL_RESOURCE_ATTRIBUTES&lt;/code&gt;, and an &lt;code&gt;x-otlp-api-key&lt;/code&gt; header for the dashboard's ingest endpoint. That is why nothing in either &lt;code&gt;Program.cs&lt;/code&gt; names a collector: the orchestrator names it, on every child process it starts, including the two &lt;code&gt;dapr run&lt;/code&gt; executables. Two of the injected values are Development-only, and they explain a mismatch people blame on their APM vendor: locally you get &lt;code&gt;OTEL_TRACES_SAMPLER=always_on&lt;/code&gt; and one-second export intervals, and in production Aspire sets neither, leaving you on the OTel SDK's own defaults. Dashboards that feel instant on a laptop lag in production for that reason and no other.&lt;/p&gt;

&lt;p&gt;Take the same service and deploy it to App Service or a plain container without that variable and you get zero telemetry with zero warnings. Setting the variable is the fix, not editing &lt;code&gt;Extensions.cs&lt;/code&gt;. And resist the reflex of adding your vendor's OTLP exporter beside the defaults: &lt;code&gt;UseOtlpExporter&lt;/code&gt; is single-shot, so a second call, or a signal-specific &lt;code&gt;AddOtlpExporter()&lt;/code&gt; on the same service collection, throws &lt;code&gt;NotSupportedException&lt;/code&gt; at startup rather than warning.&lt;/p&gt;

&lt;p&gt;That is the whole inheritance, and from the consuming side it is one line in &lt;code&gt;OrderService/Program.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Dapr.Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;DaprAspireDemo.OrderService.Inventory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;DaprAspireDemo.OrderService.Orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddServiceDefaults&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDaprClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Keyed singleton rather than AddHttpClient, to keep the app ID out of reach of service discovery.&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddKeyedSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HttpClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="n"&gt;InventoryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AppId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DaprClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateInvokeHttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;!));&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;InventoryClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

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

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

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two frameworks, two lines, and neither knows the other exists. &lt;code&gt;AddServiceDefaults&lt;/code&gt; never mentions Dapr. &lt;code&gt;AddDaprClient&lt;/code&gt; never mentions Aspire: it reads &lt;code&gt;DAPR_HTTP_PORT&lt;/code&gt; and &lt;code&gt;DAPR_GRPC_PORT&lt;/code&gt; out of its own environment, exactly as it did in Part 3, and the only thing that changed is who set them. There it was &lt;code&gt;dapr run&lt;/code&gt; from the multi-app file. Here it is the hosting integration, one layer further out. The defaults 3500 and 50001 appear nowhere in this file precisely because nothing in it assumes a port.&lt;/p&gt;

&lt;p&gt;Service invocation itself runs over an ordinary &lt;code&gt;HttpClient&lt;/code&gt;. &lt;code&gt;CreateInvokeHttpClient&lt;/code&gt; gives the client a base address whose host is the app ID, &lt;code&gt;inventory-service&lt;/code&gt; over plain http, and installs the handler that rewrites each request into &lt;code&gt;{daprEndpoint}/v1.0/invoke/inventory-service/method/{path}&lt;/code&gt;, and that factory is the supported surface now that &lt;code&gt;DaprClient.InvokeMethodAsync&lt;/code&gt; has carried &lt;code&gt;[Obsolete]&lt;/code&gt; since the Dapr .NET SDK 1.17.&lt;/p&gt;

&lt;p&gt;The keyed registration in the middle is the one line in the file that is a judgement call, and it deserves a more honest defence than "the factory would break", because the factory does not break. Registering the same client through &lt;code&gt;AddHttpClient&lt;/code&gt;, same &lt;code&gt;inventory-service&lt;/code&gt; base address, same Dapr &lt;code&gt;InvocationHandler&lt;/code&gt;, resolved through &lt;code&gt;IHttpClientFactory&lt;/code&gt;, returns HTTP 201 on &lt;code&gt;POST /orders&lt;/code&gt;. Two independent launches, no exception at &lt;code&gt;CreateClient&lt;/code&gt; and none at request time.&lt;/p&gt;

&lt;p&gt;It works because of what &lt;code&gt;AddServiceDiscovery()&lt;/code&gt; registers by default: a pass-through provider. A host name with no matching configuration entry is not an error, it is a name that gets handed onward untouched to ordinary DNS. A pair of probe clients in one launch proves both halves of that sentence live. The one with a configuration entry resolved and then failed at connect with &lt;code&gt;Connection refused (localhost:59999)&lt;/code&gt;, so service discovery is genuinely in the pipeline and genuinely rewriting. The one without an entry failed at the socket with &lt;code&gt;nodename nor servname provided, or not known (inventory-service:80)&lt;/code&gt;, so the URI reached DNS with the app ID still in it. On the real invoke client the Dapr handler intercepts before any socket opens and rewrites the authority to the sidecar, so the DNS failure never happens.&lt;/p&gt;

&lt;p&gt;The trap is on the other side of that condition, and it is one line in the AppHost away. Add &lt;code&gt;.WithReference(inventory)&lt;/code&gt; to the order-service resource, the ordinary service-discovery overload and not the obsolete component one from the AppHost earlier, and Aspire hands the order-service process four new environment variables: the lowercase &lt;code&gt;http&lt;/code&gt; and &lt;code&gt;https&lt;/code&gt; service-discovery entries, and the uppercase &lt;code&gt;INVENTORY_SERVICE_HTTP&lt;/code&gt; and &lt;code&gt;INVENTORY_SERVICE_HTTPS&lt;/code&gt; forms of the same thing. A full environment diff against a control launch found those four and nothing else. Service discovery now has something to resolve, so it rewrites the request before Dapr ever sees it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;services__inventory-service__http__0=http://localhost:5049

http://inventory-service/stock/check  -&amp;gt;  http://localhost:5049/stock/check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler then reads &lt;code&gt;uri.Host&lt;/code&gt; off the rewritten URI, which drops the port, and the sidecar is asked to invoke a service called &lt;code&gt;localhost&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"errorCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ERR_DIRECT_INVOKE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"failed to invoke, id: localhost, err: couldn't find service: localhost"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;POST /orders&lt;/code&gt; comes back 503. I ran that rather than inferring it from the error message: same AppHost, the factory registration in place, 503 on the order and that body verbatim out of the sidecar. Seeing the rewrite behind it took a separate run. A second client in the same launch, same base address, service discovery in its pipeline and no &lt;code&gt;InvocationHandler&lt;/code&gt;, reached inventory-service's own Kestrel and came back 200, which is only possible if something had already replaced the authority.&lt;/p&gt;

&lt;p&gt;Run that same AppHost against the sample as it ships and the reference changes nothing: 201, 200, 409, byte-identical to a control launch without it. The registration style, not the AppHost, is what decides the outcome. &lt;code&gt;DaprClient.CreateInvokeHttpClient&lt;/code&gt; builds its client directly, so it never passes through &lt;code&gt;IHttpClientFactory&lt;/code&gt;, &lt;code&gt;ConfigureHttpClientDefaults&lt;/code&gt; never reaches it, and the injected configuration sits there inert. The two mechanisms are not exclusive, they are ordered, and service discovery is first. Keeping the invoke client out of the factory keeps the app ID out of the pipeline service discovery reads, which is a smaller claim than "the factory would break" and the one the runs actually support.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AddStandardResilienceHandler&lt;/code&gt; in the shared defaults is quietly wrapping every factory-created client in retries, a circuit breaker and two layers of timeout, which is a good thing to know before you write your own retry loop on top of it; Part 5 pulls that apart properly, because it belongs to the migration story rather than to this one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;InventoryService/Program.cs&lt;/code&gt; is the shortest argument in the whole sample. It calls &lt;code&gt;AddServiceDefaults&lt;/code&gt;, maps its endpoints, and stops. Its project file references &lt;code&gt;DaprAspireDemo.ServiceDefaults&lt;/code&gt; and nothing else: no &lt;code&gt;Dapr.Client&lt;/code&gt;, no &lt;code&gt;Dapr.AspNetCore&lt;/code&gt;, no Dapr type anywhere in its source. It is reachable as &lt;code&gt;inventory-service&lt;/code&gt; because the AppHost attached a sidecar with that app ID, and Part 3's asymmetry survives the move intact. Being callable by app ID still costs the callee zero lines.&lt;/p&gt;

&lt;h3&gt;
  
  
  The health endpoints disappear outside Development
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;MapDefaultEndpoints&lt;/code&gt; is nine lines of code, and the first of them is a condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt; &lt;span class="nf"&gt;MapDefaultEndpoints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Adding health checks endpoints to applications in non-development environments has security implications.&lt;/span&gt;
    &lt;span class="c1"&gt;// See https://aka.ms/aspire/healthchecks for details before enabling these endpoints in non-development environments.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsDevelopment&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// All health checks must pass for app to be considered ready to accept traffic after starting&lt;/span&gt;
        &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapHealthChecks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HealthEndpointPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Only health checks tagged with the "live" tag must pass for app to be considered alive&lt;/span&gt;
        &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapHealthChecks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AlivenessEndpointPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;HealthCheckOptions&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Predicate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"live"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Outside Development neither &lt;code&gt;/health&lt;/code&gt; nor &lt;code&gt;/alive&lt;/code&gt; is mapped. Not "returns 503", not "returns an empty body": there is no route. Point an AKS readiness probe or a Container Apps health probe at &lt;code&gt;/health&lt;/code&gt;, get a 404, and the rollout stalls with a pod that never goes ready and not one log line anywhere mentioning health checks. &lt;a href="https://aspire.dev/fundamentals/health-checks/" rel="noopener noreferrer"&gt;aspire.dev states it plainly&lt;/a&gt;: "In non-development environments, the &lt;code&gt;/health&lt;/code&gt; and &lt;code&gt;/alive&lt;/code&gt; endpoints are disabled by default."&lt;/p&gt;

&lt;p&gt;The reason is defensible. An unauthenticated &lt;code&gt;/health&lt;/code&gt; that fans out to your database, cache and message broker on every request is a DoS amplifier that anyone on the internet can aim at your dependencies, and its response body enumerates those dependencies by name.&lt;/p&gt;

&lt;p&gt;Before you go rewriting them, get the tag semantics right, because the version repeated in most posts is backwards. &lt;code&gt;/health&lt;/code&gt; is mapped with &lt;strong&gt;no predicate&lt;/strong&gt;, so it runs every registered check, including the ones tagged &lt;code&gt;live&lt;/code&gt;. &lt;code&gt;/alive&lt;/code&gt; filters to &lt;code&gt;live&lt;/code&gt; only, so untagged checks never reach it. A probe app registering the template's &lt;code&gt;"self"&lt;/code&gt; check plus a second untagged check returning &lt;code&gt;Unhealthy&lt;/code&gt; answers 503 on &lt;code&gt;/health&lt;/code&gt; and 200 on &lt;code&gt;/alive&lt;/code&gt; at the same moment, which is exactly the Kubernetes semantic you want: a dead dependency should fail readiness and pull the pod out of the load balancer, and must not fail liveness and trigger a restart that cannot possibly help. Every Aspire client integration that registers a check (&lt;code&gt;AddNpgsqlDataSource&lt;/code&gt;, &lt;code&gt;AddRedisClient&lt;/code&gt;) registers it untagged, so this is the behaviour you inherit whether you thought about it or not.&lt;/p&gt;

&lt;p&gt;The fix is not deleting the &lt;code&gt;if&lt;/code&gt;. The documented alternative keeps the endpoints cheap and quiet: a 5-second request timeout policy, a 10-second output cache so a probe storm hits the cache rather than the database, both applied through a &lt;code&gt;MapGroup&lt;/code&gt;, and host filtering or authorization on that group so only the platform's probe can reach it. That is a handful of lines in the same method, and it is the variant to copy.&lt;/p&gt;

&lt;p&gt;One consequence reaches back into the AppHost. &lt;code&gt;WithHttpHealthCheck("/health")&lt;/code&gt; combined with &lt;code&gt;WaitFor&lt;/code&gt; is what turns "start B after A" into "start B after A is actually answering", which is the mechanism &lt;code&gt;orders.WaitFor(inventory)&lt;/code&gt; leaned on back in the AppHost. If the endpoint it probes exists only in Development, that gate exists only in Development too, and nothing tells you when it stops applying.&lt;/p&gt;

&lt;h2&gt;
  
  
  One request, seven spans
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;POST /orders&lt;/code&gt; does three things: it takes an order, asks &lt;code&gt;inventory-service&lt;/code&gt; whether the lines can be filled, and writes the accepted order to the state store. Two of those cross a process boundary, and nothing in the order-service source names a host, a port or a URL to make them happen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:5037/orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"orderId":"ORD-1001","customerId":"CUST-42","lines":[{"sku":"AZ-KEYBOARD","quantity":2,"unitPrice":79.99},{"sku":"AZ-MOUSE","quantity":1,"unitPrice":24.50}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"orderId":"ORD-1001","customerId":"CUST-42","lines":[...],"total":184.48,"status":0,"placedAt":"2026-08-28T06:47:16.941436+00:00"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HTTP 201, with the total computed from the lines the inventory service agreed to reserve. Order something the warehouse does not have and the same endpoint answers 409 with the shortfall rather than a stack trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"orderId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ORD-1002"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"shortfalls"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"AZ-DOCK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"requested"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"onHand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Part 3's run file could produce both of those responses. What it could not produce is the next block. The dashboard's trace detail for &lt;code&gt;order-service: POST /orders/&lt;/code&gt; reports Duration 0.18s, Resources 4, Depth 5, Total spans 7, and the tree behind those numbers is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /orders/                                     order-service
  HTTP POST 200                                   order-service
    HTTP POST                                     order-service-dapr-cli
      CallLocal/inventory-service/stock/check     inventory-service-dapr-cli
        POST /stock/check                         inventory-service
  DATA state /dapr.proto.runtime.v1.Dapr/SaveState  order-service-dapr-cli
  HTTP POST 200 order-service-dapr-cli            order-service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The right-hand column is the resource each span came from, and it is the column that makes this worth printing. Both &lt;code&gt;*-dapr-cli&lt;/code&gt; resources are in it. The sidecar is not a black box that the trace jumps over: &lt;code&gt;order-service-dapr-cli&lt;/code&gt; records the outbound &lt;code&gt;HTTP POST&lt;/code&gt; it received, and &lt;code&gt;inventory-service-dapr-cli&lt;/code&gt; records &lt;code&gt;CallLocal/inventory-service/stock/check&lt;/code&gt;, the sidecar-to-sidecar hop, as a real span with the callee's own ASP.NET Core span nested underneath it. The &lt;code&gt;SaveState&lt;/code&gt; call is there too, as its own span on the order sidecar, tagged with the gRPC method the SDK actually invoked (&lt;code&gt;/dapr.proto.runtime.v1.Dapr/SaveState&lt;/code&gt;) rather than with anything the application wrote. Four resources, one trace, and not a line of telemetry configuration in either &lt;code&gt;Program.cs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two separate things have to be true for that tree to exist, and only one of them is about the dashboard. The first is instrumentation: the single &lt;code&gt;AddServiceDefaults&lt;/code&gt; call in each service registers inbound ASP.NET Core instrumentation on both services and outbound &lt;code&gt;HttpClient&lt;/code&gt; instrumentation on the caller, so order-service writes a &lt;code&gt;traceparent&lt;/code&gt; on the way out and inventory-service reads one on the way in. The second is delivery: the AppHost sets &lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/code&gt; on the two service processes &lt;em&gt;and&lt;/em&gt; on the two &lt;code&gt;dapr run&lt;/code&gt; executables, along with the protocol, a per-resource &lt;code&gt;OTEL_SERVICE_NAME&lt;/code&gt; and the dashboard's OTLP API key, so all four processes ship to the same collector without any of them knowing where it is. Miss the difference between those two and you will draw the wrong conclusion the first time a trace comes back fragmented: curl a callee directly, with no caller upstream, and you get a separate trace with a single span in it, because spans link on propagated context and not on a shared destination.&lt;/p&gt;

&lt;p&gt;Before you build a habit around that dashboard, know what it is and is not. Its telemetry is capped at 10,000 traces and 10,000 logs shared across every resource, and &lt;a href="https://aspire.dev/dashboard/standalone/" rel="noopener noreferrer"&gt;Microsoft's own framing of the scope&lt;/a&gt; is that "the dashboard is designed as a development and short-term diagnostic tool", which "persists telemetry in-memory" and where "no telemetry is persisted when the dashboard is restarted". And you reach it through a one-time &lt;code&gt;?t=&lt;/code&gt; token, which is regenerated on every launch, on whichever port the template wrote into the AppHost's &lt;code&gt;launchSettings.json&lt;/code&gt; when it scaffolded the project. That port is randomised once, at scaffold time, and then pinned: 17004 for this sample on every run, and some other five-digit number for yours. Either way, any instruction that tells you to browse to &lt;code&gt;localhost:18888&lt;/code&gt; is describing the standalone dashboard container, not the one &lt;code&gt;aspire run&lt;/code&gt; just printed a URL for.&lt;/p&gt;

&lt;p&gt;One open risk belongs here rather than in a footnote, because it is specifically a multi-sidecar risk. &lt;a href="https://github.com/CommunityToolkit/Aspire/issues/1509" rel="noopener noreferrer"&gt;CommunityToolkit/Aspire#1509&lt;/a&gt;, open since August 2026, reports one sidecar per launch dying while the dashboard continues to show it as &lt;code&gt;Finished&lt;/code&gt;, with a rotating victim and the &lt;code&gt;dapr&lt;/code&gt; CLI's serialised startup as the suspected cause. I never saw it across three launches on Aspire 13.5.3 with toolkit 13.0.0: both sidecars reached &lt;code&gt;Running&lt;/code&gt;, stayed there, answered &lt;code&gt;/v1.0/metadata&lt;/code&gt; and contributed spans every time. Three launches against a race, with no attempt on my part to provoke it, is evidence of absence and not much more. Read it as a reason to check the resource list rather than as a reason to assume the issue is gone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Restarting the app proves nothing about durability
&lt;/h3&gt;

&lt;p&gt;The dashboard puts a Restart button next to every resource, which makes it the obvious way to answer "does my data survive a restart?". It answers a different question.&lt;/p&gt;

&lt;p&gt;Restart &lt;code&gt;order-service&lt;/code&gt; from the dashboard and the app process is genuinely replaced: PID 54221 became PID 60531, and the start time in the resource list moved with it. Neither daprd moved. &lt;code&gt;order-service-dapr-cli&lt;/code&gt; stayed on PID 54223 with the dashboard still showing its original start time, and &lt;code&gt;inventory-service-dapr-cli&lt;/code&gt; stayed on 54222. The sidecar is a top-level resource in its own right, and restarting its app does not touch it.&lt;/p&gt;

&lt;p&gt;That is a useful property most of the time and a trap once. Because the sidecar survives, anything the sidecar was holding survives with it, and a state store living inside daprd's own memory is exactly that. &lt;code&gt;GET /orders/ORD-1001&lt;/code&gt; on an order written before the restart came back HTTP 200 on the configuration with no &lt;code&gt;LocalPath&lt;/code&gt; set, the one that resolves to &lt;code&gt;state.in-memory&lt;/code&gt;. The store that loses everything the moment the sidecar dies passes the restart-the-app test with full marks. Restart the &lt;code&gt;*-dapr-cli&lt;/code&gt; resource instead and the two configurations finally disagree: 404 with the generated in-memory component, 200 with the repository's Redis one. That is the test worth writing down.&lt;/p&gt;

&lt;p&gt;Two small details from the same menu. An app resource offers Stop, Restart and Rebuild; a &lt;code&gt;*-dapr-cli&lt;/code&gt; resource offers Stop and Restart with no Rebuild, which is one more reminder that the sidecar is an executable Aspire launched and not a project it built. And Restart is not Rebuild even where both exist: the product's own description of it is "Source code is not recompiled."&lt;/p&gt;

&lt;h3&gt;
  
  
  If you switch to the http profile
&lt;/h3&gt;

&lt;p&gt;The AppHost template writes two launch profiles, &lt;code&gt;https&lt;/code&gt; and &lt;code&gt;http&lt;/code&gt;, and on Aspire 13.5.3 the second one does not start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unhandled exception. System.AggregateException: One or more errors occurred. (The 'applicationUrl'
setting must be an https address unless the 'ASPIRE_ALLOW_UNSECURED_TRANSPORT' environment variable
is set to true. ...)
 ---&amp;gt; Microsoft.Extensions.Options.OptionsValidationException: The 'applicationUrl' setting must be
an https address unless the 'ASPIRE_ALLOW_UNSECURED_TRANSPORT' environment variable is set to true.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The template-generated &lt;code&gt;http&lt;/code&gt; profile does not set that variable, so the profile the template ships is dead on arrival. This is the good kind of failure: loud, immediate, and naming the variable it wants.&lt;/p&gt;

&lt;p&gt;Getting far enough to see it is the awkward half. &lt;code&gt;aspire run&lt;/code&gt; has no &lt;code&gt;--launch-profile&lt;/code&gt; flag at all, and with &lt;code&gt;AspireUseCliBundle=true&lt;/code&gt; the &lt;code&gt;dotnet run --launch-profile http&lt;/code&gt; you would reach for next is routed through &lt;code&gt;aspire run&lt;/code&gt; and the profile is dropped on the floor: the AppHost came up with &lt;code&gt;DOTNET_LAUNCH_PROFILE=https&lt;/code&gt; regardless, with nothing in the output admitting the switch was ignored. Any instruction of the form "run it with the http profile" has to say how, which in this case meant running the built AppHost binary directly with the profile's environment variables set by hand.&lt;/p&gt;

&lt;p&gt;Once &lt;code&gt;ASPIRE_ALLOW_UNSECURED_TRANSPORT=true&lt;/code&gt; is set and the app comes up, the interesting part is that nothing about the trace changes. Same tree, 4 resources, depth 5, 7 spans, both sidecars contributing, over a plain http OTLP endpoint, with &lt;code&gt;OTEL_EXPORTER_OTLP_INSECURE&lt;/code&gt; never set on either daprd process. The insecure-endpoint story you may have read about Dapr and OTLP is not what bites you on this stack; the launch profile is.&lt;/p&gt;

&lt;p&gt;One more observation from reading those environments, because it explains where the sidecars' configuration is &lt;em&gt;not&lt;/em&gt; coming from. Both daprd processes were started with &lt;code&gt;--config ~/.dapr/config.yaml&lt;/code&gt;, and that flag arrives the same way &lt;code&gt;--components-path&lt;/code&gt; does: it is the &lt;code&gt;dapr&lt;/code&gt; CLI's own default, not something the integration passed. The file it points at declares only a Zipkin exporter and has no OTLP section anywhere in it, yet both sidecars exported to the dashboard anyway. daprd 1.18.3 honours the &lt;code&gt;OTEL_EXPORTER_OTLP_*&lt;/code&gt; variables independently of the Dapr Configuration resource. I did not check whether the spans reached Zipkin at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the model stops
&lt;/h2&gt;

&lt;p&gt;Everything above is one machine. The introduction called the AppHost a development-time orchestrator on Microsoft's own authority, and this is the section that says what that costs you, because the parts of the model that do not cross the deployment boundary do not announce that they are staying behind.&lt;/p&gt;

&lt;p&gt;Start with the components, because that is the one people find last. An AppHost carrying &lt;code&gt;AddAzureContainerAppEnvironment("cae").WithDaprComponents()&lt;/code&gt; alongside an &lt;code&gt;AddDaprStateStore&lt;/code&gt; and an &lt;code&gt;AddDaprPubSub&lt;/code&gt; publishes cleanly: &lt;code&gt;aspire publish -o ./out&lt;/code&gt; reported all five steps succeeded. The &lt;code&gt;cae.bicep&lt;/code&gt; it wrote contains a managed identity, a container registry, a role assignment, a Log Analytics workspace, the &lt;code&gt;Microsoft.App/managedEnvironments&lt;/code&gt; resource itself, the dashboard's &lt;code&gt;dotNetComponents&lt;/code&gt; resource, and the outputs. It contains no &lt;code&gt;Microsoft.App/managedEnvironments/daprComponents&lt;/code&gt; resource of any kind. The two components declared in C# have no representation anywhere in the emitted infrastructure.&lt;/p&gt;

&lt;p&gt;Reading the publishing code explains that rather than excusing it. &lt;code&gt;WithDaprComponents&lt;/code&gt; invokes a component's publishing action only when that component carries an &lt;code&gt;AzureDaprComponentPublishingAnnotation&lt;/code&gt;, and a plain &lt;code&gt;AddDaprStateStore&lt;/code&gt; is never given one. The annotation comes from the Azure helper packages, in practice from &lt;code&gt;CommunityToolkit.Aspire.Hosting.Azure.Dapr.Redis&lt;/code&gt;, and only when you call &lt;code&gt;WithReference&lt;/code&gt; on the component with an &lt;code&gt;AddAzureManagedRedis&lt;/code&gt; resource as the argument; &lt;code&gt;AddRedis&lt;/code&gt; and &lt;code&gt;AddAzureRedis&lt;/code&gt; do not qualify, and neither does the local YAML file that made everything work on your laptop. The place not to look for the missing components is azd. It deploys the bicep it was handed, and the bicep never had them.&lt;/p&gt;

&lt;p&gt;Sidecar enablement does survive publish, and one value in it needs checking before anything reaches a cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dapr: { enabled: true, appId: 'svc', appProtocol: 'http', appPort: 8080, logLevel: 'info', enableApiLogging: false }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;appPort&lt;/code&gt; was not inferred. The probe container behind that line listens on 80, and the bicep still said 8080, because the publishing code falls back to a literal 8080 whenever &lt;code&gt;DaprSidecarOptions.AppPort&lt;/code&gt; is unset. Port inference from the app's allocated endpoint happens at run time only, and has no publish-mode equivalent. A container that does not happen to listen on 8080 gets a deployed sidecar that cannot reach it, and the first symptom is a health check rather than an error about ports.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AppPort&lt;/code&gt; is also one of the survivors, which is the more useful way to read that line. Of the 32 properties on &lt;code&gt;DaprSidecarOptions&lt;/code&gt;, the publishing path translates five into an ACA sidecar: &lt;code&gt;AppId&lt;/code&gt;, &lt;code&gt;AppPort&lt;/code&gt;, &lt;code&gt;EnableApiLogging&lt;/code&gt;, &lt;code&gt;LogLevel&lt;/code&gt; and &lt;code&gt;AppProtocol&lt;/code&gt;. The other 27 have no destination in the emitted bicep, including every &lt;code&gt;AppHealth*&lt;/code&gt; property, the port overrides, &lt;code&gt;ResourcesPaths&lt;/code&gt;, and &lt;code&gt;Config&lt;/code&gt;. &lt;code&gt;Config&lt;/code&gt; being dropped costs nothing in practice, because &lt;a href="https://learn.microsoft.com/azure/container-apps/dapr-overview" rel="noopener noreferrer"&gt;Azure Container Apps lists the Dapr Configuration spec first among the things it does not support&lt;/a&gt;: the capability is not available on that platform whether Aspire forwards the setting or not.&lt;/p&gt;

&lt;p&gt;Which matters mostly because Aspire never writes a Dapr &lt;code&gt;Configuration&lt;/code&gt; resource in the first place. The integration passes &lt;code&gt;--config&lt;/code&gt; only when you point &lt;code&gt;DaprSidecarOptions.Config&lt;/code&gt; at a file you wrote yourself, and there is no C# surface that produces one. Tracing sampling rates, mTLS, access control lists, resiliency policies, middleware pipelines: all of it is hand-written YAML on the local side, and a separate question again about what the target platform accepts.&lt;/p&gt;

&lt;p&gt;Line those up and they resolve into one uncomfortable symmetry. &lt;code&gt;components/statestore.yaml&lt;/code&gt;, the one file you had to write by hand because the generated alternative was an in-memory store, is the artifact from this whole exercise that crosses the boundary intact. It is a real file, in source control, describing a real Redis instance, and getting it onto Container Apps or Kubernetes is an ordinary deployment problem with ordinary answers. The component the AppHost wrote for you lived in a temp directory that was deleted at shutdown and has no counterpart on the far side of publish. The half you had to do by hand is the half that ships.&lt;/p&gt;

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

&lt;p&gt;Both files are deleted at the deployment boundary, so whatever the C# one is worth had to show up before then, and it does. One &lt;code&gt;aspire run&lt;/code&gt; started four processes, held the caller back until the callee's health check answered rather than until it happened to be up, and put seven spans from four resources into one trace, with no line in either &lt;code&gt;Program.cs&lt;/code&gt; naming a collector, a port, or the other service. &lt;code&gt;dapr run -f .&lt;/code&gt; starts the same four processes and hands you back a terminal.&lt;/p&gt;

&lt;p&gt;What you give up for it is one thing rather than a list. Everything the AppHost inferred for you it inferred at run time and nowhere else: the component YAML, the wiring, the ports, the health-gated ordering. None of it is waiting on the far side of &lt;code&gt;aspire publish&lt;/code&gt;, and the one Dapr artifact the AppHost could not generate, because the generated version would have quietly lost your data, is the one you can actually deploy. Treat that as the rule for how much of this model to trust: whatever the AppHost saved you from writing, you will write eventually, and the sooner you write it the less of it is a surprise.&lt;/p&gt;

&lt;p&gt;Part 5 takes the same boundary from the other side, moving an existing Functions app across it one piece at a time, and works out what happens when Dapr's built-in service-invocation retries, the standard resilience handler ServiceDefaults installs on every &lt;code&gt;HttpClient&lt;/code&gt;, and your own retry loop all fire on the same failed request.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>dotnet</category>
      <category>cloudnative</category>
      <category>aspire</category>
    </item>
    <item>
      <title>Building Your First Dapr + Web API Service</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 28 Aug 2026 06:08:23 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/building-your-first-dapr-web-api-service-3p2g</link>
      <guid>https://dev.to/martin_oehlert/building-your-first-dapr-web-api-service-3p2g</guid>
      <description>&lt;p&gt;Part 2 ended on a trade: Dapr removes the part of your code that names a backing service, and a second process in every replica is the price. The interesting question is what the first half costs on the C# side, because "no vendor names" is not the same as "no vendor constraints". Two projects are enough to find out: one that calls through Dapr, and one that only gets called.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two services, two app IDs
&lt;/h2&gt;

&lt;p&gt;Start with the two project files, because the difference between them is the argument.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- OrderApi/OrderApi.csproj --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;Project&lt;/span&gt; &lt;span class="na"&gt;Sdk=&lt;/span&gt;&lt;span class="s"&gt;"Microsoft.NET.Sdk.Web"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;ItemGroup&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"Dapr.AspNetCore"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"Dapr.Client"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/ItemGroup&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Project&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- InventoryApi/InventoryApi.csproj --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;Project&lt;/span&gt; &lt;span class="na"&gt;Sdk=&lt;/span&gt;&lt;span class="s"&gt;"Microsoft.NET.Sdk.Web"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- No Dapr package reference, on purpose. inventory-api is reached BY app ID, it does not
       reach anything by app ID, so it never touches the SDK. Being callable through Dapr costs
       a target service exactly zero lines of code. --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Project&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;orders-api&lt;/code&gt; owns order state and calls out. &lt;code&gt;inventory-api&lt;/code&gt; answers stock checks and calls nothing, so it carries no Dapr package, no Dapr type, and no Dapr configuration. Being addressable as &lt;strong&gt;&lt;code&gt;inventory-api&lt;/code&gt;&lt;/strong&gt; is a property of how the process was started, not of how it was written: the app ID is a runtime label the sidecar owns.&lt;/p&gt;

&lt;p&gt;That is also why this is two projects rather than one binary started twice under two app IDs: running one assembly twice would hide the asymmetry that matters, which is that only one of the two services is coupled to Dapr at all. Versions are pinned centrally at &lt;code&gt;Dapr.Client&lt;/code&gt; and &lt;code&gt;Dapr.AspNetCore&lt;/code&gt; &lt;strong&gt;1.18.5&lt;/strong&gt;, the same pair Part 2 used; &lt;code&gt;AddDaprClient&lt;/code&gt; lives in &lt;code&gt;Dapr.AspNetCore&lt;/code&gt; and the &lt;code&gt;DaprClient&lt;/code&gt; it registers in &lt;code&gt;Dapr.Client&lt;/code&gt;, which is why &lt;code&gt;orders-api&lt;/code&gt;, which only wants the client, still takes both packages. Both projects, the run file and the components folder are in &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/DaprWebApiDemo" rel="noopener noreferrer"&gt;DaprWebApiDemo in azure-functions-samples&lt;/a&gt;, and its README carries the local run loop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feh54386lo6dg3duawhei.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feh54386lo6dg3duawhei.png" alt="Two Minimal API services, one sidecar each, sharing one orderstore component" width="712" height="1066"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The wiring on the calling side is two registrations in &lt;code&gt;Program.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="c1"&gt;// Source-generated JSON, front of the chain. The reflection resolver stays behind it because&lt;/span&gt;
&lt;span class="c1"&gt;// the framework serialises types this app never declares (ProblemDetails, most obviously).&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureHttpJsonOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SerializerOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TypeInfoResolverChain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderApiJsonContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// The same generated metadata, handed to Dapr. AddDaprClient reads DAPR_HTTP_PORT /&lt;/span&gt;
&lt;span class="c1"&gt;// DAPR_GRPC_PORT from the environment, so 3500 and 50001 are never written down here.&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDaprClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dapr&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;dapr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseJsonSerializationOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderApiJsonContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddDaprClient&lt;/code&gt; is the whole registration, and Part 2 covered the two environment variables behind it. The remaining lines of &lt;code&gt;Program.cs&lt;/code&gt; build the &lt;code&gt;HttpClient&lt;/code&gt; that reaches &lt;code&gt;inventory-api&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The callee's &lt;code&gt;Program.cs&lt;/code&gt; is the same file with the Dapr line removed:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="c1"&gt;// The only wiring this service needs. No AddDaprClient, because it never calls out through a&lt;/span&gt;
&lt;span class="c1"&gt;// sidecar; it only gets called through one.&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureHttpJsonOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SerializerOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TypeInfoResolverChain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;InventoryApiJsonContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

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

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  One JSON context, wired two different ways
&lt;/h3&gt;

&lt;p&gt;Both services declare their wire types up front, which on &lt;code&gt;orders-api&lt;/code&gt; looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSourceGenerationOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonSerializerDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Web&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreateOrderRequest&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderLine&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OutOfStockResponse&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ErrorResponse&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ConfirmConflictResponse&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancelOrderRequest&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancelOrderResponse&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderCancellation&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancelConflictResponse&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StockCheckRequest&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StockCheckResponse&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StockLine&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StockShortfall&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DaprErrorBody&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderApiJsonContext&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;JsonSerializerContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two registrations above hand that context to their serializers in different shapes. Dapr gets &lt;code&gt;UseJsonSerializationOptions(OrderApiJsonContext.Default.Options)&lt;/code&gt;, a hard swap: the only things the client serialises are state values and invocation payloads, every one of them is on the list above, and there is nothing left for a fallback to catch. Minimal API instead gets the context &lt;strong&gt;inserted at the front of the resolver chain&lt;/strong&gt;, because the framework serialises types this file will never list: &lt;code&gt;ProblemDetails&lt;/code&gt;, the bare strings that come out of &lt;code&gt;Results.BadRequest&lt;/code&gt;, and whatever the next middleware decides to write. Swap the reflection resolver out there and you break responses you never wrote.&lt;/p&gt;

&lt;p&gt;The asymmetry decides how a mistake reaches you. Add a type to a response, forget the matching &lt;code&gt;[JsonSerializable]&lt;/code&gt; line, and the Dapr path fails on it immediately while the HTTP path quietly falls through to reflection and works. The quiet side is the one that breaks when somebody publishes trimmed or AOT.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;JsonSerializerDefaults.Web&lt;/code&gt; on the attribute is the other line to read twice. It is what keeps the generated metadata in step with ASP.NET Core's own defaults: camelCase names, case-insensitive reads, numbers accepted from strings. Leave it off and nothing fails to compile; a property just silently stays null at runtime, on the far side of a service call, where you will look for it last.&lt;/p&gt;

&lt;h3&gt;
  
  
  Starting both, with a sidecar each
&lt;/h3&gt;

&lt;p&gt;Two apps means two sidecars, and the CLI has a &lt;strong&gt;multi-app run file&lt;/strong&gt; for exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;common&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ASPNETCORE_ENVIRONMENT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Development&lt;/span&gt;
&lt;span class="na"&gt;apps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;appID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders-api&lt;/span&gt;
    &lt;span class="na"&gt;appDirPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;appProtocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
    &lt;span class="na"&gt;appPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5100&lt;/span&gt;
    &lt;span class="na"&gt;resourcesPaths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./components/local&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dotnet"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--project"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./OrderApi"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--no-build"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--urls"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:5100"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;appID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inventory-api&lt;/span&gt;
    &lt;span class="na"&gt;appDirPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;appProtocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
    &lt;span class="na"&gt;appPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5101&lt;/span&gt;
    &lt;span class="na"&gt;resourcesPaths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./components/local&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dotnet"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--project"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./InventoryApi"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--no-build"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--urls"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:5101"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet build
dapr run &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dapr run -f .&lt;/code&gt; reads that file, starts both projects with a &lt;code&gt;daprd&lt;/code&gt; sidecar each, and points both at &lt;code&gt;components/local&lt;/code&gt;. &lt;code&gt;dapr stop -f .&lt;/code&gt; takes the set back down. Two details in the YAML cost time if you skip them: every relative path is resolved against &lt;code&gt;appDirPath&lt;/code&gt;, which is why both apps keep &lt;code&gt;appDirPath: .&lt;/code&gt; and select their project on the command line instead, and &lt;code&gt;appPort&lt;/code&gt; is a declaration rather than an instruction. It tells the sidecar which port to call your app on; the &lt;code&gt;--urls&lt;/code&gt; argument after the &lt;code&gt;--&lt;/code&gt; is what makes Kestrel actually listen there. The two are set separately and nothing checks that they agree, so keep them in view of each other.&lt;/p&gt;

&lt;p&gt;The component behind &lt;code&gt;resourcesPaths&lt;/code&gt; is unchanged from Part 2: a &lt;code&gt;state.redis&lt;/code&gt; component named &lt;code&gt;orderstore&lt;/code&gt; locally, a &lt;code&gt;state.azure.cosmosdb&lt;/code&gt; component with the same &lt;code&gt;metadata.name&lt;/code&gt; in &lt;code&gt;components/azure&lt;/code&gt;, and no C# that knows the difference. One line in it belongs to this section's point rather than to Part 2's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;orders-api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inventory-api&lt;/code&gt; is deliberately absent. It holds no state, so it has no reason to be able to reach the store, and without a &lt;code&gt;scopes&lt;/code&gt; list every Dapr app in the environment loads every component. The service that costs zero lines of Dapr code also gets zero Dapr permissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling a service you cannot name
&lt;/h2&gt;

&lt;p&gt;Service invocation in the current SDK is a client you register rather than a method you call. &lt;code&gt;DaprClient.CreateInvokeHttpClient&lt;/code&gt; hands back a configured &lt;code&gt;HttpClient&lt;/code&gt;, and from there the call site is plain ASP.NET Core: a path, a DTO, &lt;code&gt;PostAsJsonAsync&lt;/code&gt;. (The &lt;code&gt;InvokeMethodAsync&lt;/code&gt; overloads that used to do this are marked &lt;code&gt;[Obsolete]&lt;/code&gt; &lt;a href="https://github.com/dapr/dotnet-sdk/releases/tag/v1.17.0" rel="noopener noreferrer"&gt;as of 1.17&lt;/a&gt; and point at the same factory, so new code and migrated code land in the same place.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Service invocation runs over an ordinary HttpClient. CreateInvokeHttpClient sets BaseAddress&lt;/span&gt;
&lt;span class="c1"&gt;// to http://inventory-api and installs the handler that rewrites the request into&lt;/span&gt;
&lt;span class="c1"&gt;// {daprEndpoint}/v1.0/invoke/inventory-api/method/{path}. One client per target app ID is the&lt;/span&gt;
&lt;span class="c1"&gt;// documented pattern (an app ID containing an uppercase letter only works when it is passed&lt;/span&gt;
&lt;span class="c1"&gt;// here), so the app ID doubles as the DI key.&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddKeyedSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HttpClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="n"&gt;InventoryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AppId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DaprClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateInvokeHttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;!));&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;InventoryClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;InventoryClient.AppId&lt;/code&gt; is the constant &lt;code&gt;"inventory-api"&lt;/code&gt;, and under &lt;a href="https://docs.dapr.io/developing-applications/building-blocks/service-invocation/service-invocation-overview/" rel="noopener noreferrer"&gt;Dapr's service invocation model&lt;/a&gt; that is the entire address. No host, no port, no service discovery configuration anywhere in &lt;code&gt;orders-api&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A second factory does the same thing, which matters when you are reading someone else's code. &lt;code&gt;DaprClient.CreateInvokeHttpClient(appId)&lt;/code&gt; is static. &lt;code&gt;daprClient.CreateInvokableHttpClient(appId)&lt;/code&gt; is an instance method, and &lt;a href="https://github.com/dapr/dotnet-sdk/blob/master/src/Dapr.Client/DaprClientGrpc.cs" rel="noopener noreferrer"&gt;its implementation&lt;/a&gt; is a one-line delegation to the static one: it passes along the &lt;code&gt;DaprClient&lt;/code&gt;'s own HTTP endpoint and API token header. The two are alternatives rather than an old form and a new one. Reach for the instance form when the &lt;code&gt;DaprClient&lt;/code&gt; you already hold was configured with a non-default endpoint or an API token, which saves repeating both at every call site. For a client built once at startup, as above, the static form is the one that fits.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the handler does, and what it refuses to do
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;&lt;a href="https://github.com/dapr/dotnet-sdk/blob/master/src/Dapr.Client/InvocationHandler.cs" rel="noopener noreferrer"&gt;&lt;code&gt;InvocationHandler&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; that factory installs is a &lt;code&gt;DelegatingHandler&lt;/code&gt; with a short job list. It reads the request URI, treats the host as the target app ID, and rewrites the request onto the sidecar's invoke route. If an API token is configured it adds a &lt;code&gt;dapr-api-token&lt;/code&gt; header for the duration of the call. Then it passes the response back to you completely unmodified: no status inspection, no exception, no wrapping of any kind.&lt;/p&gt;

&lt;p&gt;That last part is the one to plan around. A non-2xx from the target arrives as an ordinary &lt;code&gt;HttpResponseMessage&lt;/code&gt;, an unreachable sidecar as an ordinary &lt;code&gt;HttpRequestException&lt;/code&gt;, and nothing in between knows what an app ID is. Error translation is your code's job, and the sample gives it a home. (Teams arriving from &lt;code&gt;InvokeMethodAsync&lt;/code&gt; give up &lt;code&gt;InvocationException&lt;/code&gt; and its &lt;code&gt;.AppId&lt;/code&gt;, &lt;code&gt;.MethodName&lt;/code&gt; and &lt;code&gt;.Response&lt;/code&gt; in the move; &lt;a href="https://github.com/dapr/dotnet-sdk/issues/1695" rel="noopener noreferrer"&gt;the SDK does not offer a replacement&lt;/a&gt;.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StockCheckResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CheckStockAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;StockCheckRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// A plain POST. "/stock/check" is the route on the target app; the app ID lives in&lt;/span&gt;
        &lt;span class="c1"&gt;// the BaseAddress, and the handler turns the pair into&lt;/span&gt;
        &lt;span class="c1"&gt;// {daprEndpoint}/v1.0/invoke/inventory-api/method/stock/check.&lt;/span&gt;
        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"/stock/check"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;OrderApiJsonContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StockCheckRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsSuccessStatusCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;TranslateFailureAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;OrderApiJsonContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StockCheckResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;Fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InvocationFailure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvalidResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"inventory-api returned an empty body."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StockCheckResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;.&lt;/span&gt;&lt;span class="nf"&gt;Success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TaskCanceledException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCancellationRequested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// HttpClient reports its own timeout as a cancellation, so the guard is what&lt;/span&gt;
        &lt;span class="c1"&gt;// separates "we gave up" from "the caller went away".&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Stock check for {OrderId} timed out."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InvocationFailure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"inventory-api did not answer in time."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpRequestException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Connection-level: daprd itself is not listening. Nothing was routed.&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Could not reach the Dapr sidecar for a stock check on {OrderId}."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InvocationFailure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SidecarUnreachable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"The Dapr sidecar is not reachable."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;InventoryClient&lt;/code&gt; is the only class in &lt;code&gt;orders-api&lt;/code&gt; that knows &lt;code&gt;inventory-api&lt;/code&gt; exists. It owns the wire contract and the error translation, and &lt;code&gt;Fail&lt;/code&gt; is a one-liner that wraps an &lt;code&gt;InvocationError&lt;/code&gt; into &lt;code&gt;Result&amp;lt;StockCheckResponse&amp;gt;.Failure&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;when (!cancellationToken.IsCancellationRequested)&lt;/code&gt; guard on the first catch earns its keep. &lt;code&gt;HttpClient&lt;/code&gt; reports its own timeout as a &lt;code&gt;TaskCanceledException&lt;/code&gt;, the same type you get when the incoming request is abandoned and ASP.NET Core cancels the token. Without the guard, a caller who closed their browser and an upstream service that stopped answering produce the same log line and the same response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failures as values
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TValue&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;Success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TValue&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TValue&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InvocationError&lt;/span&gt; &lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TValue&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;InvocationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InvocationFailure&lt;/span&gt; &lt;span class="n"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;InvocationFailure&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;TargetUnreachable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SidecarUnreachable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;UpstreamError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;InvalidResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The private constructor on &lt;code&gt;Result&amp;lt;TValue&amp;gt;&lt;/code&gt; is what makes this worth typing out rather than reaching for a &lt;code&gt;bool&lt;/code&gt; and an out parameter. No type outside the file can derive from it, so &lt;code&gt;Success&lt;/code&gt; and &lt;code&gt;Failure&lt;/code&gt; are the only cases that can ever exist, and a &lt;code&gt;switch&lt;/code&gt; over them is exhaustive by construction rather than by convention. The create-order handler leans on that directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CheckStockAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stockRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StockCheckResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;.&lt;/span&gt;&lt;span class="nf"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ToProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StockCheckResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;.&lt;/span&gt;&lt;span class="nf"&gt;Success&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="n"&gt;Available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Rejected order {OrderId}: inventory short on {Count} line(s)."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Shortfalls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Conflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OutOfStockResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Shortfalls&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Inventory said no" and "inventory could not be asked" sit side by side in one statement instead of being split across a &lt;code&gt;try&lt;/code&gt; and a &lt;code&gt;catch&lt;/code&gt; twenty lines apart. That is the argument for the &lt;strong&gt;result pattern&lt;/strong&gt; here: through Dapr, a call failing is not exceptional. The target app ID has no address yet, the target is mid-restart, the sidecar is still warming up. Those are Tuesday afternoon, not an incident.&lt;/p&gt;

&lt;p&gt;The five &lt;code&gt;InvocationFailure&lt;/code&gt; cases are deliberately coarser than HTTP status codes, because the caller's question is never "was that a 502 or a 504". It is "retry, give up, or blame the payload". Which is exactly why the client bothers to open the sidecar's error body at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StockCheckResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;TranslateFailureAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;HttpResponseMessage&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAsStringAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;daprError&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;TryReadDaprError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daprError&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;ErrorCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DirectInvokeErrorCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"Sidecar could not route to {AppId}: {Detail}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AppId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daprError&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InvocationFailure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TargetUnreachable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;$"Dapr could not route to '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;AppId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;'."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"inventory-api answered {Status}: {Body}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InvocationFailure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpstreamError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;$"inventory-api answered &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the sidecar cannot route to an app ID it answers HTTP 500 with a JSON body whose &lt;code&gt;errorCode&lt;/code&gt; is &lt;strong&gt;&lt;code&gt;ERR_DIRECT_INVOKE&lt;/code&gt;&lt;/strong&gt;. Read only the status and that is indistinguishable from &lt;code&gt;inventory-api&lt;/code&gt; throwing on your request. The distinction is the whole point: in the first case nothing ran on the far side, so the call is worth making again once the target registers; in the second, the target ran and rejected you, and repeating it changes nothing. &lt;code&gt;DirectInvokeErrorCode&lt;/code&gt; is a constant on the client holding that string, and &lt;code&gt;TryReadDaprError&lt;/code&gt; is best-effort, catching &lt;code&gt;JsonException&lt;/code&gt; and returning null, because a target app is free to return whatever it likes in an error body and non-JSON there is normal.&lt;/p&gt;

&lt;p&gt;Those two categories then have to reach the HTTP caller as different answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;IResult&lt;/span&gt; &lt;span class="nf"&gt;ToProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InvocationError&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kind&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;InvocationFailure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TargetUnreachable&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;InvocationFailure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SidecarUnreachable&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Inventory is unavailable."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status503ServiceUnavailable&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

    &lt;span class="n"&gt;InvocationFailure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Timeout&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Inventory did not answer in time."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status504GatewayTimeout&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Inventory rejected the stock check."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status502BadGateway&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A routing failure becomes 503 and an error the target produced becomes 502, which is that same distinction reaching your caller as advice about whether to try again. Both were HTTP 500 on the wire a moment ago. Retries, backoff, and circuit breakers on top of this belong to Dapr's own resiliency policies rather than to your handler, and Part 5 covers them where the migration story makes the trade-offs concrete.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you would rather speak gRPC
&lt;/h3&gt;

&lt;p&gt;Calls between sidecars go over gRPC no matter what, so choosing gRPC for your own leg changes how your process talks to the process next to it and nothing about the hop between services. The factory is &lt;code&gt;DaprClient.CreateInvocationInvoker(appId)&lt;/code&gt;, which is static and returns a &lt;code&gt;Grpc.Core.CallInvoker&lt;/code&gt; that injects the target app ID (and the API token) into outgoing gRPC metadata. You hand that invoker to a client generated from the target service's &lt;code&gt;.proto&lt;/code&gt;, which is the real .NET-side cost: HTTP invocation needs a DTO and an &lt;code&gt;HttpClient&lt;/code&gt;, gRPC invocation needs a contract both sides agree on and a build step that generates from it. The sample stays on HTTP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The token nobody checks
&lt;/h2&gt;

&lt;p&gt;The header everyone reaches for first is the wrong one. &lt;code&gt;dapr-app-id&lt;/code&gt; is an &lt;strong&gt;outbound routing header&lt;/strong&gt;. You put it on a request to your own sidecar to say which app you want to reach, as an alternative to rewriting the URL. It is an instruction, not a claim, and Dapr never verifies it on the way in. An endpoint filter that reads &lt;code&gt;dapr-app-id&lt;/code&gt; and rejects anything unexpected blocks precisely nobody, because any caller who can reach your app port can set that header to any string they like.&lt;/p&gt;

&lt;p&gt;The primitive that does mean something is &lt;code&gt;dapr-api-token&lt;/code&gt;, and the part that catches people out is the &lt;strong&gt;token asymmetry&lt;/strong&gt;. One header name, two directions, two environment variables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DAPR_API_TOKEN&lt;/code&gt; &lt;a href="https://docs.dapr.io/operations/security/api-token/" rel="noopener noreferrer"&gt;secures your app's &lt;strong&gt;outbound&lt;/strong&gt; calls&lt;/a&gt; to its own sidecar. The SDK reads that variable itself and attaches the header, including on the &lt;code&gt;HttpClient&lt;/code&gt; from the previous section. No application code in &lt;code&gt;orders-api&lt;/code&gt; touches it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;APP_API_TOKEN&lt;/code&gt; secures the sidecar's &lt;strong&gt;inbound&lt;/strong&gt; calls into your app: service invocation arriving from another app, pub/sub deliveries, input bindings. The runtime sends it on the same &lt;code&gt;dapr-api-token&lt;/code&gt; header, and checking it is entirely your problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://docs.dapr.io/operations/security/app-api-token/" rel="noopener noreferrer"&gt;Dapr's guidance for the second one&lt;/a&gt; is a sentence: look for the header. There is no ASP.NET Core code on the page, which is presumably why so few services have any check at all.&lt;/p&gt;

&lt;p&gt;In a Minimal API, an &lt;strong&gt;&lt;a href="https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/min-api-filters?view=aspnetcore-10.0" rel="noopener noreferrer"&gt;endpoint filter&lt;/a&gt;&lt;/strong&gt; is where it goes, and a class rather than a lambda, because the check needs configuration and a logger.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/orders"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddEndpointFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DaprApiTokenFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapCreateOrder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGetOrder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapConfirmOrder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapCancelOrder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applying it at the group rather than per endpoint is what makes it hard to bypass: &lt;code&gt;AddEndpointFilter&lt;/code&gt; covers every endpoint added to the group afterwards, so a new slice cannot forget the token check by forgetting a line. ASP.NET Core constructs the filter through &lt;code&gt;ActivatorUtilities&lt;/code&gt;, so &lt;code&gt;IConfiguration&lt;/code&gt; and &lt;code&gt;ILogger&amp;lt;T&amp;gt;&lt;/code&gt; arrive by constructor injection with no registration of the filter type anywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DaprApiTokenFilter&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IEndpointFilter&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;HeaderName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"dapr-api-token"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]?&lt;/span&gt; &lt;span class="n"&gt;expectedToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DaprApiTokenFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;DaprApiTokenFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IConfiguration&lt;/span&gt; &lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DaprApiTokenFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"APP_API_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="n"&gt;expectedToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedToken&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"APP_API_TOKEN is not set; inbound Dapr calls are not authenticated."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;ValueTask&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EndpointFilterInvocationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EndpointFilterDelegate&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ArgumentNullException&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThrowIfNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;ArgumentNullException&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThrowIfNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedToken&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;presented&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;HeaderName&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="c1"&gt;// Exactly one value. A repeated header is a caller trying something.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;presented&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;presented&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;expectedToken&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"Rejected {Method} {Path}: missing or invalid dapr-api-token."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Unauthorized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// FixedTimeEquals is false for a length mismatch without leaking where the difference&lt;/span&gt;
        &lt;span class="c1"&gt;// is. A token is a shared secret, so the ordinary string comparison is the wrong tool.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;CryptographicOperations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FixedTimeEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions in there will make you stop and squint.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CryptographicOperations.FixedTimeEquals&lt;/code&gt; instead of &lt;code&gt;==&lt;/code&gt; is the smaller one. An ordinary string comparison returns as soon as two bytes differ, and that difference in timing is a signal a patient caller can measure. The &lt;code&gt;presented.Count != 1&lt;/code&gt; test next to it rejects a request carrying the header twice: a client with a legitimate token sends it once, and a duplicated header is somebody probing which value the framework picks.&lt;/p&gt;

&lt;p&gt;The larger decision is that the filter &lt;strong&gt;fails open&lt;/strong&gt;. With &lt;code&gt;APP_API_TOKEN&lt;/code&gt; unset there is no expected token, and every request goes through with a warning logged once. That looks backwards for a security filter, and it is the only behaviour that works: with no token configured, the sidecar has nothing to send, so a filter that closed would reject Dapr itself and leave you debugging 401s from your own runtime. It also keeps a plain &lt;code&gt;curl&lt;/code&gt; against the app port working on a laptop. Anywhere that is not a laptop, set the variable.&lt;/p&gt;

&lt;p&gt;The filter is constructed once per endpoint rather than once per request, so that "not authenticated" warning appears at startup and never again. Four routes in the group, four warnings, then silence: a registration receipt, not a repeated alarm.&lt;/p&gt;

&lt;p&gt;What this prevents is not "a caller with the wrong app ID". Your application listens on its own port, and the sidecar is one more client of that port. Anything else with network access to the process, a misconfigured ingress rule, a pod in the same namespace, a colleague's port-forward, reaches your endpoints directly and bypasses Dapr entirely: no mTLS, no access control policy, no trace. The one thing such a caller cannot produce is a token it was never given.&lt;/p&gt;

&lt;h2&gt;
  
  
  Order state, and the write that loses
&lt;/h2&gt;

&lt;p&gt;Every order endpoint touches state, and the calls look nearly alike without being alike. Creating an order writes a value that did not exist a second ago. Confirming one reads a value, changes a field, and writes it back. The second shape is the one a key-value store cannot make safe on its own, and the SDK's method names tell you so before the documentation does.&lt;/p&gt;

&lt;p&gt;The create handler ends on a single call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// State management. SaveStateAsync has no ETag parameter at all: an unconditional&lt;/span&gt;
&lt;span class="c1"&gt;// write is last-write-wins by construction. That is the right call for a create, and&lt;/span&gt;
&lt;span class="c1"&gt;// the wrong call for the confirm in the sibling slice.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// The metadata is the Cosmos DB partition, and it is on all four slices or none of&lt;/span&gt;
&lt;span class="c1"&gt;// them. OrderPartition says why; this is the write that decides where the document&lt;/span&gt;
&lt;span class="c1"&gt;// lands, so it is the one to get right first.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dapr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveStateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;StateStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderPartition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;For&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;StateStore.Name&lt;/code&gt; is the constant &lt;code&gt;"orderstore"&lt;/code&gt;, which is the component's &lt;code&gt;metadata.name&lt;/code&gt; and the only string this application knows about its own database. The key it hands over is &lt;code&gt;order.OrderId&lt;/code&gt;; what lands in Redis locally and in Cosmos DB in Azure is &lt;code&gt;orders-api||ORD-1001&lt;/code&gt;, with the app-ID prefix Part 2 covered added by the sidecar rather than by the SDK.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SaveStateAsync&lt;/code&gt; has &lt;strong&gt;no ETag parameter&lt;/strong&gt;, in any overload: not an optional one, not a nullable one. Its signature is store, key, value, &lt;code&gt;StateOptions&lt;/code&gt;, metadata, cancellation token, and nothing in there can carry a version, which makes an unconditional save &lt;strong&gt;last-write-wins&lt;/strong&gt; by construction. Optimistic concurrency in this SDK is opt-in by choosing a different method rather than by passing an extra argument. For a create that is the behaviour you want: the value being written was computed from the request, not from prior state, so there is no earlier version whose contents you could be silently discarding.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;metadata&lt;/code&gt; argument is a decision rather than an API, and the transactions section argues it out. The rule it follows: the partition value is the same on every call that touches an order key, which is why it appears on the create, the read, and the confirm alike.&lt;/p&gt;

&lt;p&gt;Reading it back is the same shape in reverse, and the route is the first place the decision shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetOrderAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DaprClient&lt;/span&gt; &lt;span class="n"&gt;dapr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ArgumentNullException&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThrowIfNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dapr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dapr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetStateAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="n"&gt;StateStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderPartition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;For&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Cosmos DB enforces the second half of this on its own: an order from another customer&lt;/span&gt;
    &lt;span class="c1"&gt;// is in another partition and comes back null. Redis ignores the metadata and hands over&lt;/span&gt;
    &lt;span class="c1"&gt;// whatever sits under the key, so the check is what keeps both stores answering alike.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route is &lt;code&gt;/orders/{customerId}/{orderId}&lt;/code&gt; rather than &lt;code&gt;/orders/{orderId}&lt;/code&gt;, and that is not a REST preference. The partition value lives inside the document you have not read yet, so a caller has to name the partition to address the order at all. Committing to a partition key reaches the URL, not just the storage layer.&lt;/p&gt;

&lt;p&gt;A missing key is not an error in Dapr. &lt;code&gt;GetStateAsync&lt;/code&gt; returns the default value for &lt;code&gt;TValue&lt;/code&gt;, so a key that was never written and a key holding nothing look the same from inside the handler. Turning either into a 404 is your application's decision; the runtime has no opinion. The customer check beside it collapses "no such order" and "not your order" into the same 404 on purpose, since answering them differently would tell an unauthenticated caller which order IDs exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  The confirm is a loop, not a call
&lt;/h3&gt;

&lt;p&gt;Confirming an order is a read-modify-write against a key other replicas of &lt;code&gt;orders-api&lt;/code&gt; are free to write at the same moment. The conditional save is &lt;code&gt;TrySaveStateAsync&lt;/code&gt;, which takes an &lt;strong&gt;ETag&lt;/strong&gt; and returns &lt;code&gt;bool&lt;/code&gt;: &lt;code&gt;false&lt;/code&gt; means the store's current ETag no longer matches the one you presented. No exception, no status code to unpick. Part 2 spent real space on the fact that an ETag mismatch on the raw HTTP API is not a 409 but a 500-class body with &lt;code&gt;ERR_STATE_SAVE&lt;/code&gt; and "possible etag mismatch" buried in the text. The .NET client absorbs that and gives you a branch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ConfirmOrderAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DaprClient&lt;/span&gt; &lt;span class="n"&gt;dapr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ArgumentNullException&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThrowIfNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dapr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;partition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OrderPartition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;For&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;MaxAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;etag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dapr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetStateAndETagAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="n"&gt;StateStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ConsistencyMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Strong&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// A missing key comes back as a default value plus some ETag rather than throwing,&lt;/span&gt;
        &lt;span class="c1"&gt;// so the null check has to come before anything reads the order. The customer check&lt;/span&gt;
        &lt;span class="c1"&gt;// beside it is what Redis will not do for you: on Cosmos DB an order belonging to&lt;/span&gt;
        &lt;span class="c1"&gt;// someone else is simply in another partition and never arrives.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Confirmed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Already done. Re-confirming is a no-op, which is what makes this endpoint&lt;/span&gt;
            &lt;span class="c1"&gt;// safe to retry from the outside as well as the inside.&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;confirmed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Confirmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ConfirmedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dapr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrySaveStateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;StateStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;confirmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;etag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;StateOptions&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// With an ETag attached the store already behaves first-write-wins; saying&lt;/span&gt;
                &lt;span class="c1"&gt;// so explicitly keeps the intent readable next to the ETag itself.&lt;/span&gt;
                &lt;span class="n"&gt;Concurrency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ConcurrencyMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FirstWrite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Consistency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ConsistencyMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Strong&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Confirmed order {OrderId} on attempt {Attempt}."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;confirmed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"Lost the ETag race on order {OrderId}, attempt {Attempt} of {MaxAttempts}."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MaxAttempts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// A little backoff. A tight loop under contention just restates the race at speed.&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMilliseconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;25&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Gave up confirming order {OrderId} after {MaxAttempts} attempts."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MaxAttempts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Conflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ConfirmConflictResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MaxAttempts&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MaxAttempts&lt;/code&gt; is a private constant set to 5, and the bound is not decoration. An unbounded version of this loop is a livelock with good manners: under sustained contention it never returns and never errors, which is worse for whoever is holding the request open than a conflict would be.&lt;/p&gt;

&lt;p&gt;The line that carries the weight is the position of the read. &lt;code&gt;GetStateAndETagAsync&lt;/code&gt; sits at the top of the loop body, not above it. Hoist it out to save a round trip and every retry re-presents the ETag the store has already moved past, which is precisely the ETag that cannot match. The save then fails five times for the same reason it failed once, the backoff makes each failure slower than the last, and the endpoint returns a conflict that no amount of retrying would ever have resolved. Re-reading is not an optimisation on top of the retry; re-reading is what a retry consists of.&lt;/p&gt;

&lt;p&gt;The early return on an already-confirmed order does something separate and easy to read past: it makes the endpoint &lt;strong&gt;idempotent&lt;/strong&gt; before it makes it concurrency-safe. Inside the loop, a lost race whose winner also confirmed the order terminates on the next pass instead of burning attempts on a write that would be a no-op anyway. Outside the loop, a caller who never saw the response (a timeout, a dropped connection, a queue redelivering) can send the same request again and get the same 200 back. Without that check the second confirm would either overwrite a &lt;code&gt;ConfirmedAt&lt;/code&gt; timestamp somebody else already committed or fight for the right to do so.&lt;/p&gt;

&lt;p&gt;Then there is &lt;code&gt;ConsistencyMode.Strong&lt;/code&gt;, passed twice. The &lt;code&gt;StateOptions&lt;/code&gt; on the save draws the eye, but it is the third positional argument on the read that decides whether this loop means what it says. Dapr assumes data stores are eventually consistent by default, and under &lt;code&gt;ConsistencyMode.Eventual&lt;/code&gt; a read can come back from a replica that has not caught up. The ETag you were handed is then already behind the store's authoritative copy, and a conditional save that returns &lt;code&gt;true&lt;/code&gt; has told you something narrower than "nobody changed this while I was working": it has told you that a comparison against whichever copy answered went through. &lt;code&gt;Strong&lt;/code&gt; on the read is what makes the ETag a claim about the current value.&lt;/p&gt;

&lt;p&gt;One gap the sample steps around rather than solves. What the ETag in that tuple contains when the key does not exist is not something the code relies on: it inspects the value, returns 404, and never looks at the string. If you are tempted to branch on an empty ETag to mean "this key is new", verify that against the store you actually run, because the SDK's contract does not promise it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bulk is not a transaction
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.dapr.io/developing-applications/building-blocks/state-management/state-management-overview/" rel="noopener noreferrer"&gt;Dapr's own overview&lt;/a&gt; describes bulk operations as submitting multiple requests individually to the underlying store and returning the results as a single batch. Read that sentence for what it withholds. A batch is a round-trip optimisation: one call from your process to the sidecar instead of N, with the sidecar doing the fan-out. It is not a unit of work. &lt;code&gt;SaveBulkStateAsync&lt;/code&gt; with five items can leave you with three items written and two not, and the SDK's return type has no room to tell you which. On the wire it is not even a distinct operation; it is the same &lt;a href="https://docs.dapr.io/reference/api/state_api/" rel="noopener noreferrer"&gt;&lt;code&gt;POST /v1.0/state/&amp;lt;store&amp;gt;&lt;/code&gt; array body&lt;/a&gt; Part 2 described for a single save, with more entries in the array.&lt;/p&gt;

&lt;p&gt;The atomic one is &lt;strong&gt;&lt;code&gt;ExecuteStateTransactionAsync&lt;/code&gt;&lt;/strong&gt;, and only against a store that declares transaction support. Against Blob Storage or Table Storage the call fails outright rather than partially, which is the better failure of the two. Its operations are &lt;code&gt;Upsert&lt;/code&gt; or &lt;code&gt;Delete&lt;/code&gt; only; there is no read inside a transaction, so every value you commit was read before the transaction was built, which puts you back on the ETag machinery from the previous section if the read matters. The retry shape is not the same one, though. &lt;code&gt;ExecuteStateTransactionAsync&lt;/code&gt; returns a bare &lt;code&gt;Task&lt;/code&gt;, so there is no &lt;code&gt;false&lt;/code&gt; to branch on the way &lt;code&gt;TrySaveStateAsync&lt;/code&gt; gives you, and a conflict can only reach you as an exception.&lt;/p&gt;

&lt;p&gt;The bulk signatures carry a second surprise, visible only if you go looking for a parameter that is not there.&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="n"&gt;SaveBulkStateAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TValue&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;storeName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SaveStateItem&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TValue&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;DeleteBulkStateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;storeName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BulkDeleteStateItem&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every other state method on &lt;code&gt;DaprClient&lt;/code&gt; takes &lt;code&gt;StateOptions&lt;/code&gt; and a metadata dictionary as top-level parameters. These two take neither, because consistency, concurrency, ETag and metadata all live &lt;strong&gt;per item&lt;/strong&gt;, on &lt;code&gt;SaveStateItem&amp;lt;TValue&amp;gt;&lt;/code&gt; and &lt;code&gt;BulkDeleteStateItem&lt;/code&gt;. That is a fair reflection of what the call is: N independent writes that happen to travel together, each with its own ETag and its own opinion about consistency, and nothing global left to configure.&lt;/p&gt;

&lt;p&gt;Which brings up the part nobody warns you about. Non-actor state in &lt;a href="https://docs.dapr.io/reference/components-reference/supported-state-stores/setup-azure-cosmosdb/" rel="noopener noreferrer"&gt;Cosmos DB partitions by the item's own state key&lt;/a&gt;, so two orders under two keys land on two partitions, by design and by default. Cosmos DB also requires every item in one transaction to share a partition. Put those together and a multi-key &lt;code&gt;ExecuteStateTransactionAsync&lt;/code&gt; against Cosmos DB can fail on partitioning alone, with data that is entirely consistent and code that is entirely correct in its own terms. The fix is the same &lt;code&gt;metadata&lt;/code&gt; bag every state method carries, with an explicit &lt;code&gt;partitionKey&lt;/code&gt; set to the same value on every operation in the set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The serialisation the transactional API does not do for you.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;orderBytes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SerializeToUtf8Bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderApiJsonContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;auditBytes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SerializeToUtf8Bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancellation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderApiJsonContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderCancellation&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dapr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteStateTransactionAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;StateStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;StateTransactionRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orderBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StateOperationType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Upsert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;StateTransactionRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;CancellationKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;auditBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StateOperationType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Upsert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;StateTransactionRequest&lt;/code&gt; takes the value as &lt;code&gt;byte[]?&lt;/code&gt;, which is why the two &lt;code&gt;SerializeToUtf8Bytes&lt;/code&gt; calls are sitting there in the open. Every other state method on the client serialises for you; this one hands the job back.&lt;/p&gt;

&lt;p&gt;Nothing in the .NET signature hints at the partition constraint either. &lt;code&gt;metadata&lt;/code&gt; is an opaque string dictionary forwarded to the component verbatim, and which keys are legal is entirely store-specific, so the compiler cannot help and neither can IntelliSense.&lt;/p&gt;

&lt;p&gt;Then comes the part that makes this a design decision rather than a parameter. The default partition value is the key the &lt;em&gt;component&lt;/em&gt; sees, which is the prefixed &lt;code&gt;orders-api||ORD-1001&lt;/code&gt; from Part 2, and application code cannot reproduce that without hardcoding its own app ID. An explicit &lt;code&gt;partitionKey&lt;/code&gt; therefore never equals the default: it always moves the key somewhere else. The override is not scoped to the transaction, it is a property of where that document now lives, so every later read and write of those keys has to carry the same override or Cosmos DB looks in the partition the state key implies and finds nothing there. Adding one transaction to an existing key relocates it, permanently, for every code path that touches it. Decide the partitioning before the first write, not on the day you need two keys to commit together.&lt;/p&gt;

&lt;p&gt;Redis has no equivalent constraint, running the set through &lt;code&gt;MULTI&lt;/code&gt;/&lt;code&gt;EXEC&lt;/code&gt; in one keyspace, which is exactly what makes the trap quiet: the transaction that works all week on your laptop is the one that fails on the store you deploy to. Actor state avoids it because Dapr derives the partition key from the app ID, actor type and actor ID rather than the state key, which guarantees one actor's writes share a partition.&lt;/p&gt;

&lt;p&gt;Whether any of this is available to you is a property of the component, not of the API, and &lt;a href="https://docs.dapr.io/reference/components-reference/supported-state-stores/" rel="noopener noreferrer"&gt;the four Azure-relevant stores&lt;/a&gt; differ more than the common &lt;code&gt;DaprClient&lt;/code&gt; surface suggests:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm5y55pg8anltht12dcax.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm5y55pg8anltht12dcax.png" alt="Azure state store capabilities: Cosmos DB and Redis support CRUD, transactions, ETag, TTL and actor state; Blob Storage and Table Storage support CRUD and ETag only" width="800" height="224"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Actor eligibility is not a flag anyone sets. Dapr's rule is that a store can back actors if it supports both transactions and ETag, which is why the last column is derivable from the two before it rather than being independent information. The practical reading of the bottom two rows: Blob Storage and Table Storage will take the &lt;code&gt;SaveStateAsync&lt;/code&gt; and &lt;code&gt;TrySaveStateAsync&lt;/code&gt; calls from the previous section and reject &lt;code&gt;ExecuteStateTransactionAsync&lt;/code&gt;, so a component swap that looks like a one-line change in YAML can remove an operation your code depends on without any C# changing at all. That is the same leak Part 2 flagged, arriving through a different door.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would not build on yet
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DaprClient&lt;/code&gt; has a &lt;code&gt;QueryStateAsync&lt;/code&gt;, and it reads like the answer to "give me every order whose status is pending". I would keep it away from anything that has to be on call.&lt;/p&gt;

&lt;p&gt;The endpoint behind it is &lt;code&gt;POST /v1.0-alpha1/state/&amp;lt;store&amp;gt;/query&lt;/code&gt;, and &lt;a href="https://docs.dapr.io/developing-applications/building-blocks/state-management/howto-state-query-api/" rel="noopener noreferrer"&gt;the how-to page&lt;/a&gt; carries an alpha banner to match. That is the polite version of the warning. The impolite version is the release history: the API has been alpha since 1.5, no release note for 1.15, 1.16, 1.17, or 1.18 mentions it moving, and alpha work beside it graduated on schedule, Bulk PubSub in 1.17 and the Jobs API in 1.18. An API that has not moved through four minor releases of a project that demonstrably does promote things is not queued behind the next one. That, rather than the word "alpha", is the reason to stay off it.&lt;/p&gt;

&lt;p&gt;The PostgreSQL situation is sharper still. The v2 component stores values as &lt;code&gt;BYTEA&lt;/code&gt; where v1 used JSONB, and JSONB was what made SQL-level filtering possible, so v2 shipped with no query support and still has none; &lt;a href="https://docs.dapr.io/reference/components-reference/supported-state-stores/setup-postgresql-v2/" rel="noopener noreferrer"&gt;Dapr's own reference page&lt;/a&gt; says so outright. v1 keeps it and is not deprecated, which leaves the component version you pick deciding whether a .NET method you already wrote does anything at all.&lt;/p&gt;

&lt;p&gt;Which stores implement it is the second reason. Query support is not a documented capability flag: Dapr's own state store table carries CRUD, transactions, ETag, TTL, actors, and workflow, and there is no query column in it. The only reliable signal is in &lt;a href="https://github.com/dapr/components-contrib/tree/main/state" rel="noopener noreferrer"&gt;&lt;code&gt;components-contrib&lt;/code&gt;&lt;/a&gt;, where a store supports the alpha API if its Go package ships a &lt;code&gt;*_query.go&lt;/code&gt;. Cosmos DB has &lt;code&gt;cosmosdb_query.go&lt;/code&gt;, Redis has &lt;code&gt;redis_query.go&lt;/code&gt;, and Blob Storage has neither, which fits a store that holds one opaque blob per key. Having to read Go source to find out whether a .NET method does anything against your configured store says plainly where this feature sits in the project.&lt;/p&gt;

&lt;p&gt;Searching for it hands you the wrong page as well. Dapr's docs have a &lt;strong&gt;Query state store&lt;/strong&gt; section with a page for Cosmos DB and a page for Redis, and neither one is about the Query API. They show you how to query the &lt;em&gt;underlying&lt;/em&gt; store with its own tools, the Cosmos DB SQL API in Data Explorer and &lt;code&gt;redis-cli&lt;/code&gt; against the keys, with the sidecar out of the picture entirely. Read at speed that looks like documented per-store support for the thing you went looking for, when it is the docs telling you to go around Dapr.&lt;/p&gt;

&lt;p&gt;Which is also the recommendation. When a read path needs "all orders where X", query the store natively behind a repository type that is the one place the vendor SDK appears, or keep an index you own: a key per status holding the order IDs, maintained by the same code that writes the order. Both are more code than one &lt;code&gt;QueryStateAsync&lt;/code&gt; call, and both still work after a component upgrade.&lt;/p&gt;

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

&lt;p&gt;Part 2 priced Dapr as a trade: the vendor names leave your code, and a second process joins every replica. Two services later, the first half holds up, and the second half is not where the cost showed up.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;inventory-api&lt;/code&gt; is the evidence for the first half. Another service reaches it by app ID, through a sidecar, and none of that reached its source code: the project file carries a comment where the package references would be, and that is the whole of it. On the calling side, the component name is the only place a backing store gets named, and moving from Redis to Cosmos DB is a different file under &lt;code&gt;components/&lt;/code&gt;, not a different &lt;code&gt;using&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What the component name does not hide is the shape of the store behind it. &lt;code&gt;/orders/{customerId}/{orderId}&lt;/code&gt; has a customer in it because Cosmos DB needs a partition before it can find a document, and an explicit &lt;code&gt;partitionKey&lt;/code&gt; on one transaction moves those keys for every code path that touches them afterwards. No vendor type appears anywhere in that, and the vendor's constraints still reach the route table.&lt;/p&gt;

&lt;p&gt;The rest of the bill is the code between your service and its sidecar. The &lt;code&gt;HttpClient&lt;/code&gt; the SDK hands you routes and does nothing else, so telling "the sidecar could not route to that app ID" apart from "the target ran and refused you" is a string constant and a best-effort JSON read you maintain yourself. The inbound token check has the same shape: Dapr defines &lt;code&gt;APP_API_TOKEN&lt;/code&gt; and sends it on every call into your app, and whether anything compares it is a filter you write from a docs page one sentence long.&lt;/p&gt;

&lt;p&gt;So the second process is not the expensive part. The expense is the code between your service and it, and how much of that code the documentation leaves to you. Part 4 hands the run file and the components folder to .NET Aspire, where the sidecar stops being something you remember to start.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>dapr</category>
      <category>dotnet</category>
      <category>cloudnative</category>
    </item>
    <item>
      <title>Introduction to Dapr for Azure Developers</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 21 Aug 2026 06:44:07 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/introduction-to-dapr-for-azure-developers-1l9h</link>
      <guid>https://dev.to/martin_oehlert/introduction-to-dapr-for-azure-developers-1l9h</guid>
      <description>&lt;p&gt;Part 1 handed the job of triggers and bindings to Dapr and then stopped, which leaves the obvious question sitting there: what does a second process in every replica give you that a NuGet package cannot? The honest answer is narrower than the pitch. Dapr does not remove your HTTP client, your message schema, or your working knowledge of Cosmos DB, and the .NET SDK makes that point better than any blog post can, having deprecated its own service invocation methods in favour of a plain &lt;code&gt;HttpClient&lt;/code&gt;. It removes exactly one thing: the part of your code that names a backing service. Whether that trade pays for the sidecar it arrives in is what I want to settle before Part 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sidecar, and what it costs you
&lt;/h2&gt;

&lt;p&gt;Dapr does not arrive as a library your application calls. It arrives as a separate process named &lt;strong&gt;&lt;code&gt;daprd&lt;/code&gt;&lt;/strong&gt; running beside yours: a second container in the same Container Apps replica or Kubernetes pod, a second OS process under &lt;code&gt;dapr run&lt;/code&gt; on your laptop. Your code never links an assembly that knows how to reach Cosmos DB. It makes a loopback call to &lt;code&gt;daprd&lt;/code&gt;, and &lt;code&gt;daprd&lt;/code&gt; makes the call that leaves the machine.&lt;/p&gt;

&lt;p&gt;Two ports carry that loopback traffic: 3500 for the Dapr HTTP API and 50001 for gRPC. Both are defaults rather than guarantees, and the environment variable reference says to read &lt;code&gt;DAPR_HTTP_PORT&lt;/code&gt; "instead of hardcoding the port value".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 3500 holds for exactly as long as nobody passes -H to dapr run.&lt;/span&gt;
&lt;span class="c1"&gt;// The CLI sets DAPR_HTTP_PORT for self-hosted runs, and the&lt;/span&gt;
&lt;span class="c1"&gt;// dapr-sidecar-injector sets it on every container in the pod.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetEnvironmentVariable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DAPR_HTTP_PORT"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"3500"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sidecar&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"http://localhost:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DAPR_GRPC_PORT&lt;/code&gt; is the same contract for 50001. Container Apps documents both ports inside the replica, so the constant would survive there. Read the variable anyway: the same binary runs on your laptop, where &lt;code&gt;-H&lt;/code&gt; and &lt;code&gt;-G&lt;/code&gt; are one flag away from moving both.&lt;/p&gt;

&lt;p&gt;Whichever protocol you picked to reach your own sidecar, &lt;strong&gt;sidecar-to-sidecar traffic is always gRPC&lt;/strong&gt;. The choice changes only how your process talks to a process on the same host.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feqeqazjshpjwmjs745ay.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feqeqazjshpjwmjs745ay.png" alt="One Container Apps replica: your .NET container and the daprd sidecar side by side, with daprd holding the only connections out to Cosmos DB, Service Bus, and Key Vault" width="799" height="323"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What it costs
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://docs.dapr.io/operations/performance-and-scalability/perf-results/" rel="noopener noreferrer"&gt;The numbers Dapr publishes&lt;/a&gt; come from AKS, three &lt;code&gt;Standard_D2s_v6&lt;/code&gt; nodes, and they spread wider than the single figure that gets quoted in every Dapr thread. At roughly 1,000 iterations per second the sidecar holds 44 to 51 MB of memory, while CPU swings by more than an order of magnitude depending on what you are calling: 4 millicores for a state get over gRPC, 17 for service invocation over HTTP, 106 for a pub/sub publish. Actors and workflow are measured at lower throughputs and cost more memory per replica, up to 241 MB on the actor stress test. Treat 50 MB as a floor, not a budget.&lt;/p&gt;

&lt;p&gt;There is a question those numbers do not answer, and I could not find anyone who does: whether the sidecar in Container Apps eats your app's allocated CPU and memory, adds to it, or comes free. No Learn page says either way. So the figures above are the only concrete ones in reach, and they were measured on AKS rather than on the platform this series is heading for.&lt;/p&gt;

&lt;h3&gt;
  
  
  The startup race
&lt;/h3&gt;

&lt;p&gt;The sidecar is not ready when your container starts. It reaches readiness "once the application is accessible on its configured port", and until then "the application cannot access the Dapr components".&lt;/p&gt;

&lt;p&gt;That ordering breaks the obvious pattern of loading configuration from a Dapr secret store in &lt;code&gt;Program.cs&lt;/code&gt;. The escape hatch is &lt;a href="https://docs.dapr.io/reference/api/health_api/" rel="noopener noreferrer"&gt;a second health endpoint&lt;/a&gt;: &lt;code&gt;GET /v1.0/healthz&lt;/code&gt; checks components, the HTTP port, and the app channel, while &lt;strong&gt;&lt;code&gt;GET /v1.0/healthz/outbound&lt;/code&gt; runs the same check without the app channel&lt;/strong&gt;, so it answers while your own startup is still in progress. Both return 204 when healthy and 500 when not, and code that tests for 200 reports a healthy sidecar as broken. Do not depend on the first one in application code: it fails for apps using the Actor and Workflow APIs and creates a circular dependency everywhere else.&lt;/p&gt;

&lt;p&gt;The .NET SDK ships &lt;code&gt;CheckOutboundHealthAsync&lt;/code&gt; and &lt;code&gt;WaitForSidecarAsync&lt;/code&gt; for this. The docs limit both to secrets and configuration retrieval, promise to remove them in a future release, and attach a caveat that will cost you an afternoon: an application that waits on &lt;code&gt;WaitForSidecarAsync&lt;/code&gt; without using actors, secret management, configuration retrieval, or workflows "will indefinitely lock up during startup", because the runtime never opens an outbound connection for it to wait on. Read "indefinitely" literally. &lt;code&gt;DAPR_HEALTH_TIMEOUT&lt;/code&gt; caps the runtime's own 60-second wait, but the SDK method is a poll loop with no timeout in it: it ends when the sidecar answers or when you cancel the token, and on the path above it does neither.&lt;/p&gt;

&lt;p&gt;The reverse race has its own answer: app health checks, off by default, enabled with &lt;code&gt;--enable-app-health-check&lt;/code&gt;, hold back every pub/sub subscription, input binding, and inbound invocation until the first probe of your app succeeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the five building blocks actually remove
&lt;/h2&gt;

&lt;p&gt;Every building block has the same two parts, and no third: an HTTP or gRPC surface on localhost, and a YAML file naming the implementation behind it. Your code calls a path, the path names a component, and a &lt;code&gt;type&lt;/code&gt; field in that YAML decides whether the name resolves to Redis or to Cosmos DB.&lt;/p&gt;

&lt;p&gt;Part 1 made the case that a Functions binding is a property of the host. This is the same contract moved out to the environment, and each of the five below bites in its own way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Service invocation: you keep the HttpClient
&lt;/h3&gt;

&lt;p&gt;The surface is one route with every verb on it, &lt;code&gt;/v1.0/invoke/&amp;lt;appID&amp;gt;/method/&amp;lt;method-name&amp;gt;&lt;/code&gt;, where &lt;code&gt;&amp;lt;appID&amp;gt;&lt;/code&gt; is a logical name, not a host. Cross-namespace targets use &lt;code&gt;&amp;lt;appID&amp;gt;.&amp;lt;namespace&amp;gt;&lt;/code&gt;, and targets that are not Dapr apps put an &lt;code&gt;HTTPEndpoint&lt;/code&gt; resource name or an FQDN in the same slot. A second form matters more for migration, because it leaves existing URLs alone and moves the routing into a &lt;code&gt;dapr-app-id&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;The .NET SDK has already walked away from its own helper for this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: the call that docs.dapr.io still teaches.&lt;/span&gt;
&lt;span class="c1"&gt;// Compiles with warning CS0618: ... is obsolete: 'Recommended guidance is to&lt;/span&gt;
&lt;span class="c1"&gt;// use a native HTTP or gRPC client for service invocation'&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;daprClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvokeMethodAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ReserveStock&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"inventory"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"reserve"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After: an ordinary HttpClient whose BaseAddress is http://inventory.&lt;/span&gt;
&lt;span class="c1"&gt;// One client per target app ID, because the app ID is the base address.&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InventoryClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DaprClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateInvokeHttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"inventory"&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InventoryClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Reservation&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ReserveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReserveStock&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/stock/reserve"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnsureSuccessStatusCode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Reservation&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CreateInvokeHttpClient&lt;/code&gt; installs an invocation handler and sets &lt;code&gt;BaseAddress&lt;/code&gt; to &lt;code&gt;http://&amp;lt;app-id&amp;gt;&lt;/code&gt;, throwing when that is not a legal hostname. Because the app ID &lt;em&gt;is&lt;/em&gt; the base address, you get one client per target app whether you wanted the ceremony or not, and the XML docs add that passing &lt;code&gt;appId&lt;/code&gt; stops being optional the moment your app ID contains an uppercase letter.&lt;/p&gt;

&lt;p&gt;Strip the pitch back and the claim gets smaller, and more interesting for it. &lt;strong&gt;Dapr does not replace your HTTP client. It replaces the service discovery, the mTLS, the retries, the tracing, and the round-robin load balancing behind it.&lt;/strong&gt; The hostname, the port, the certificate handling, and the Polly policy all go.&lt;/p&gt;

&lt;h3&gt;
  
  
  State management: the key you write is not the key that lands
&lt;/h3&gt;

&lt;p&gt;The API is asymmetric in a way that shows up the first time you read a trace: a save puts the key in a body array, a get and a delete put it in the path. Three .NET methods cover almost everything, and none is deprecated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// "statestore" is the component's metadata.name. Your code never names the type.&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;StoreName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"statestore"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order-1041"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"cust-8802"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;149.95m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// POST /v1.0/state/statestore   (the key travels in the body)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;daprClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveStateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StoreName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// GET /v1.0/state/statestore/order-1041   (and DELETE on the same path)&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;daprClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetStateAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;StoreName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Concurrency is optimistic and ETag-based. &lt;code&gt;concurrency&lt;/code&gt; takes &lt;code&gt;first-write&lt;/code&gt; or &lt;code&gt;last-write&lt;/code&gt;, &lt;code&gt;consistency&lt;/code&gt; takes &lt;code&gt;strong&lt;/code&gt; or &lt;code&gt;eventual&lt;/code&gt;, and on a save both ride in an &lt;code&gt;options&lt;/code&gt; object next to the value. Send no ETag and &lt;code&gt;first-write&lt;/code&gt; behaves as last-write-wins. Eventual is the default, because "Dapr assumes data stores are eventually consistent by default": read that again if you just configured a Cosmos DB account with strong consistency and assumed the component inherited it.&lt;/p&gt;

&lt;p&gt;Do not go looking for a status code on an ETag mismatch, and do not trust the 409 that circulates online. A save, a delete, and a get for a missing key all answer 204; a concurrency failure arrives as an error body carrying &lt;code&gt;ERR_STATE_SAVE&lt;/code&gt; and "possible etag mismatch". The 409 traces back to &lt;a href="https://github.com/dapr/dapr/issues/2619" rel="noopener noreferrer"&gt;dapr/dapr#2619&lt;/a&gt;, a proposal that never shipped.&lt;/p&gt;

&lt;p&gt;The next one shows up in the portal, not the debugger. &lt;strong&gt;Dapr prefixes every state key with your app ID, using &lt;code&gt;||&lt;/code&gt; as the separator.&lt;/strong&gt; The key your code passes is &lt;code&gt;order-1041&lt;/code&gt;; the document that lands in Cosmos DB has the id &lt;code&gt;orders-api||order-1041&lt;/code&gt;. Nothing in your code mentions the prefix and nothing in the portal explains it. Two consequences: two Dapr apps cannot read each other's state, and any existing system reading that container looks for &lt;code&gt;order-1041&lt;/code&gt; and finds nothing. The &lt;code&gt;keyPrefix&lt;/code&gt; metadata field is the lever, taking &lt;code&gt;appid&lt;/code&gt; (the default), &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;namespace&lt;/code&gt;, or &lt;code&gt;none&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two constraints here turn into component decisions later: &lt;code&gt;/transaction&lt;/code&gt; depends on a store that supports transactions, and actor state additionally requires &lt;code&gt;actorStateStore: "true"&lt;/code&gt; on a store that can do those transactions, with the docs adding that a distributed database behind it has to provide strong consistency. That is the first crack in the abstraction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pub/sub: the envelope you did not ask for
&lt;/h3&gt;

&lt;p&gt;Publishing is one call, &lt;code&gt;POST /v1.0/publish/&amp;lt;pubsubname&amp;gt;/&amp;lt;topic&amp;gt;&lt;/code&gt;, with any metadata as query parameters. Delivery is &lt;strong&gt;at-least-once&lt;/strong&gt;, so your handler has to tolerate seeing the same order twice. The subscriber acknowledges with a 2xx and a body of &lt;code&gt;{"status": "&amp;lt;status&amp;gt;"}&lt;/code&gt;, where the status is &lt;code&gt;SUCCESS&lt;/code&gt;, &lt;code&gt;RETRY&lt;/code&gt;, or &lt;code&gt;DROP&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In .NET, &lt;code&gt;PublishEventAsync(pubsubName, topicName, payload)&lt;/code&gt; sends the order. What arrives at the other end is not the order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5929aaac-a5e2-4ca1-859c-edfe73f11565"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders-api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"com.dapr.event.sent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"specversion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"datacontenttype"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-21T09:14:02Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"topic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pubsubname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orderpubsub"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"traceid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"traceparent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tracestate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"orderId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"order-1041"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"customerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cust-8802"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;149.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.dapr.io/developing-applications/building-blocks/pubsub/pubsub-cloudevents/" rel="noopener noreferrer"&gt;CloudEvents is the default&lt;/a&gt;, and it is the number one first-timer surprise.&lt;/strong&gt; Your subscriber's model binder sees &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;source&lt;/code&gt;, and &lt;code&gt;data&lt;/code&gt;, binds nothing useful, and hands your handler an &lt;code&gt;Order&lt;/code&gt; with every property null. The bug report writes itself, and the fix is one line:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseCloudEvents&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// unwraps the envelope above, so binding sees `data`&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapControllers&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapSubscribeHandler&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// serves GET /dapr/subscribe from the [Topic] attributes&lt;/span&gt;

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

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ApiController&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderEventsController&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ControllerBase&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Topic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orderpubsub"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/orders/placed"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;OnOrderPlaced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderStore&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RecordAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"SUCCESS"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;   &lt;span class="c1"&gt;// RETRY redelivers, DROP discards&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove &lt;code&gt;app.UseCloudEvents()&lt;/code&gt; and the same code compiles, runs, and silently records empty orders.&lt;/p&gt;

&lt;p&gt;The subscription itself can be declared three ways, and the operational difference matters more than the mechanics. &lt;strong&gt;Declarative&lt;/strong&gt; subscriptions are a YAML &lt;code&gt;Subscription&lt;/code&gt; resource that "removes the Dapr dependency from your code" and hot reloads by default. &lt;strong&gt;Programmatic&lt;/strong&gt; subscriptions are the &lt;code&gt;[Topic]&lt;/code&gt; attributes above, "only read once during application start-up", so a new topic means a restart. &lt;strong&gt;Streaming&lt;/strong&gt; subscriptions invert the flow: the application pulls from Dapr through &lt;code&gt;Dapr.Messaging.PublishSubscribe&lt;/code&gt;, and a subscription can start and stop at runtime.&lt;/p&gt;

&lt;p&gt;You can opt out of the envelope with &lt;code&gt;rawPayload&lt;/code&gt;, and the bill is longer than it looks: raw mode "disables support for tracing, event deduplication per messageId, content-type metadata, and any other features built using the CloudEvent schema". There is also a naming asymmetry with no defence: the publish-side key is &lt;strong&gt;&lt;code&gt;rawPayload&lt;/code&gt;&lt;/strong&gt;, while .NET and the Kubernetes CRDs spell it &lt;strong&gt;&lt;code&gt;isRawPayload&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Dapr provides dead letter topics for every pub/sub component, "even if the underlying system does not support this feature natively", set with &lt;code&gt;deadLetterTopic&lt;/code&gt; on the subscription. Then the default that catches everyone:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;By default, when a dead letter topic is set, any failing message immediately goes to the dead letter topic.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No retry, no backoff, no second chance: one transient timeout against your database and the order is in the dead letter topic. Configure a retry resiliency policy first, then enable the dead letter topic.&lt;/p&gt;

&lt;p&gt;Message TTL is &lt;code&gt;ttlInSeconds&lt;/code&gt; in metadata, enforced by the runtime, so every component supports it. Azure Service Bus is the one component with native entity-level TTL, where Dapr hands the metadata to the broker.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bindings: the trigger, minus the host
&lt;/h3&gt;

&lt;p&gt;Direction is the part everyone gets backwards. An &lt;strong&gt;output binding&lt;/strong&gt; is your application calling out through the sidecar; an &lt;strong&gt;input binding&lt;/strong&gt; is Dapr calling in, to a route on your own app port.&lt;/p&gt;

&lt;p&gt;Output binding calls go through &lt;code&gt;DaprClient.InvokeBindingAsync&lt;/code&gt;, with a required &lt;code&gt;operation&lt;/code&gt; field. The generic set is &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt;, and &lt;code&gt;exec&lt;/code&gt;, but the legal values are whatever the component implements, so &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;, and &lt;code&gt;query&lt;/code&gt; show up on some and not others; the component's reference page is the only list that binds. On the input side there is no &lt;code&gt;DaprClient&lt;/code&gt; method at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Output binding: your app calls out through the sidecar.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;daprClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;InvokeBindingAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order-archive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Input binding: Dapr calls in. The route name is the component's metadata.name.&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order-received"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderService&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HandleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// anything other than 200 OK schedules redelivery&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Dapr probes every input binding route with OPTIONS on startup and expects&lt;/span&gt;
&lt;span class="c1"&gt;// 2xx or 405. A 404 here means no events, ever.&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapMethods&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order-received"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;HttpMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;OPTIONS&lt;/code&gt; probe is the failure mode to remember, because it produces no error anywhere: a router that answers 404 never subscribes, never receives an event, and never logs a reason. The component behind the route is an ordinary &lt;code&gt;bindings.azure.storagequeues&lt;/code&gt; resource named &lt;code&gt;order-received&lt;/code&gt;, and its &lt;code&gt;direction&lt;/code&gt; property, while not required, "is highly recommended" for input bindings.&lt;/p&gt;

&lt;p&gt;Compared against &lt;code&gt;[QueueTrigger]&lt;/code&gt;, this is more ceremony, not less: an endpoint, a component file, and a name that has to match in two places, where an attribute used to do it. In exchange you get an ordinary HTTP endpoint, one that runs in a test, a console host, or any service with no Functions host around to supply the trigger. You traded brevity for portability.&lt;/p&gt;

&lt;h3&gt;
  
  
  Secrets: one API with a dangerous default
&lt;/h3&gt;

&lt;p&gt;Two routes, &lt;code&gt;/v1.0/secrets/&amp;lt;store-name&amp;gt;/&amp;lt;name&amp;gt;&lt;/code&gt; for one secret and &lt;code&gt;/bulk&lt;/code&gt; for everything, and a .NET method behind each of them. &lt;code&gt;GetSecretAsync&lt;/code&gt; is the one you will use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The declared return type is Dictionary&amp;lt;string, string&amp;gt;, not your secret.&lt;/span&gt;
&lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;daprClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSecretAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orderssecrets"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"orders-cosmos-key"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Azure Key Vault is a name/value store, so the field is the secret name:&lt;/span&gt;
&lt;span class="c1"&gt;//   { "orders-cosmos-key": "..." }&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cosmosKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"orders-cosmos-key"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// A Kubernetes store returns the inner data keys instead, and no secret name:&lt;/span&gt;
&lt;span class="c1"&gt;//   { "accountKey": "...", "accountEndpoint": "..." }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The return type is the gotcha. &lt;code&gt;GetSecretAsync&lt;/code&gt; hands back a dictionary rather than your secret, because the response shape depends on the store type. Code written against a local file store in development still compiles against Kubernetes, and then throws &lt;code&gt;KeyNotFoundException&lt;/code&gt; on the first lookup. In exchange the API takes away the vendor SDK, the chicken-and-egg of needing a secret in order to fetch secrets, and secrets in &lt;code&gt;appsettings.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The default it ships with is the one to change:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Once you configure a secret store for your application, any secret defined within that store is accessible by default from the Dapr application.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every secret. Not the ones your app asked for, not the ones it was scoped to. Narrowing it means a &lt;code&gt;secrets.scopes&lt;/code&gt; policy on the Dapr Configuration resource, where &lt;code&gt;allowedSecrets&lt;/code&gt; or &lt;code&gt;deniedSecrets&lt;/code&gt; take priority over &lt;code&gt;defaultAccess&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That fix has a sting attached. &lt;strong&gt;Azure Container Apps does not expose the Dapr Configuration spec at all&lt;/strong&gt;, so &lt;code&gt;secrets.scopes&lt;/code&gt; is not available there. Scope moves down to the Azure layer instead: a Key Vault holding only what this application needs, RBAC on that vault, and component &lt;code&gt;scopes&lt;/code&gt; limiting which app IDs load the component at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the component model leaks
&lt;/h2&gt;

&lt;p&gt;A component is a YAML resource, and four lines of it do the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dapr.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Component&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;statestore&lt;/span&gt;              &lt;span class="c1"&gt;# &amp;lt;- what your code says&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state.azure.cosmosdb&lt;/span&gt;    &lt;span class="c1"&gt;# &amp;lt;- what actually runs&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
  &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# &amp;lt;- how that implementation is configured&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;url&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://acct.documents.azure.com:443/"&lt;/span&gt;
&lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;orders-api&lt;/span&gt;                  &lt;span class="c1"&gt;# &amp;lt;- which app IDs may load it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your code addresses &lt;code&gt;statestore&lt;/code&gt; and never mentions &lt;code&gt;state.azure.cosmosdb&lt;/code&gt;, which is the entire proposition: change &lt;code&gt;spec.type&lt;/code&gt; from &lt;code&gt;state.redis&lt;/code&gt; to &lt;code&gt;state.azure.cosmosdb&lt;/code&gt;, redeploy, and the &lt;code&gt;SaveStateAsync&lt;/code&gt; call above runs unchanged. Since Dapr v1.18 you do not even restart for it: components hot reload by default, going briefly unavailable while they close and re-initialize. Actor state stores and workflow backends are carved out of that, so the component whose swap you would most like to be quiet is the one that still needs a restart.&lt;/p&gt;

&lt;p&gt;That promise holds at compile time. It leaks at the level of behaviour, in five places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capability is per component, and the code that used it stops working.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9i2u06pdam8fzm670r21.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9i2u06pdam8fzm670r21.png" alt="State store capability matrix" width="800" height="248"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Swap &lt;code&gt;statestore&lt;/code&gt; from Cosmos DB to Blob Storage without touching a line of C# and your build stays green while your &lt;code&gt;/transaction&lt;/code&gt; calls fail, your TTLs stop expiring, and your actors refuse to activate. Note the Cosmos DB row too: every capability except workflow, which is not the gap anybody predicts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Support level is per component as well.&lt;/strong&gt; Azure Service Bus Topics pub/sub is Stable since runtime 1.0; the Queues variant is still &lt;strong&gt;Beta&lt;/strong&gt;. Same building block, same publish call, different answer to "is this supported in production". Container Apps then disagrees with the project that printed the label and lists that same Beta queues component in its own fully-supported tier, so the answer also depends on who you ask.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The backing store's own constraints show through anyway.&lt;/strong&gt; Cosmos DB requires the container's partition key to be named exactly &lt;code&gt;/partitionKey&lt;/code&gt;, and the Service Bus subscription Dapr creates is named after your &lt;code&gt;consumerID&lt;/code&gt;. Neither fact appears in the Dapr API surface, and both are the first thing you see in the Azure portal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Emulation is not equivalence.&lt;/strong&gt; Dead letter topics exist "even if the underlying system does not support this feature natively", which on Service Bus leaves you owning two dead letter mechanisms with different semantics. And stores without native ETags "are expected to simulate ETags", so your optimistic concurrency is as strong as somebody else's emulation of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The physical data is not what you wrote.&lt;/strong&gt; &lt;code&gt;orders-api||order-1041&lt;/code&gt; is still in that container under a key no other system will look for.&lt;/p&gt;

&lt;p&gt;The YAML swap does what it says at compile time. The behavioural equivalence is not real, and nothing in the API surface tells you which capabilities you gave up. Treat a component change as a dependency change with a review and a test pass behind it, not as a configuration change you make on a Friday.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuo4x0h01s6ynr3wojalm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuo4x0h01s6ynr3wojalm.png" alt="One SaveStateAsync call against one statestore component: Redis and Azure Cosmos DB answer CRUD, transactions, ETag, TTL, and actors, while Azure Blob Storage answers CRUD and ETag only" width="712" height="554"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping the blocks onto Azure
&lt;/h2&gt;

&lt;p&gt;None of the three services below is new to you. A state store component is the &lt;code&gt;[CosmosDBOutput]&lt;/code&gt; binding you have already configured, a pub/sub component is the &lt;code&gt;[ServiceBusTrigger]&lt;/code&gt; you have already debugged, and a secret store is the Key Vault reference already in your app settings. What changes is ownership: the connection detail leaves &lt;code&gt;local.settings.json&lt;/code&gt; for a resource that outlives the app, and the identity belongs to the workload rather than to the Functions host.&lt;/p&gt;

&lt;p&gt;Each mapping has one detail that fails your first deploy. Cosmos DB fails on a role assignment you cannot make in the portal, Service Bus on a permission nobody documents, Key Vault on a secret name that was never legal.&lt;/p&gt;

&lt;h3&gt;
  
  
  State store to Azure Cosmos DB
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dapr.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Component&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;statestore&lt;/span&gt;                 &lt;span class="c1"&gt;# the string your SaveStateAsync call passes&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state.azure.cosmosdb&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
  &lt;span class="na"&gt;initTimeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;                  &lt;span class="c1"&gt;# sits on spec, not inside metadata&lt;/span&gt;
  &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;url&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://acct-orders.documents.azure.com:443/"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;database&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;collection&lt;/span&gt;             &lt;span class="c1"&gt;# the field is collection, not container&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orderstate"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azureClientId&lt;/span&gt;          &lt;span class="c1"&gt;# user-assigned identity&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8f3c1d2a-...-9b4e"&lt;/span&gt;
    &lt;span class="c1"&gt;# no masterKey: dropping it is what selects Entra ID authentication&lt;/span&gt;
&lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;orders-api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The data plane wants the identity's OBJECT id, not the client ID above.&lt;/span&gt;
&lt;span class="nv"&gt;PRINCIPAL_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az identity show &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; id-orders-api &lt;span class="nt"&gt;--resource-group&lt;/span&gt; rg-orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; principalId &lt;span class="nt"&gt;--output&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# ...0002 is Cosmos DB Built-in Data Contributor (...0001 is Data Reader).&lt;/span&gt;
&lt;span class="c"&gt;# There is no portal equivalent of this command.&lt;/span&gt;
az cosmosdb sql role assignment create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--account-name&lt;/span&gt; acct-orders &lt;span class="nt"&gt;--resource-group&lt;/span&gt; rg-orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--principal-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PRINCIPAL_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role-definition-id&lt;/span&gt; &lt;span class="s2"&gt;"00000000-0000-0000-0000-000000000002"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The field names are where the first hour goes. It is &lt;code&gt;collection&lt;/code&gt; on a service whose portal has called it a container for years, and &lt;code&gt;masterKey&lt;/code&gt; is documented as required "only when not using Microsoft Entra ID authentication", so dropping that one line is the switch onto the shared &lt;code&gt;azure*&lt;/code&gt; credential fields. Raise &lt;code&gt;initTimeout&lt;/code&gt; to &lt;code&gt;5m&lt;/code&gt; as the docs recommend, because Cosmos DB rate-limits metadata requests account-wide, exactly the request every cold sidecar makes at once.&lt;/p&gt;

&lt;p&gt;Then &lt;a href="https://docs.dapr.io/reference/components-reference/supported-state-stores/setup-azure-cosmosdb/" rel="noopener noreferrer"&gt;the requirement&lt;/a&gt; that rejects a container you created last week:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The partition key for the collection must be named &lt;code&gt;/partitionKey&lt;/code&gt; (note: this is case-sensitive).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For ordinary state, the Dapr key doubles as the partition key value, which is why &lt;code&gt;orders-api||order-1041&lt;/code&gt; shows up in both fields of the same document. Actor state differs: the partition key comes from app ID plus actor type plus actor ID, so everything one actor owns lands on one physical partition, because actor operations are transactional and Cosmos DB transactions are single-partition.&lt;/p&gt;

&lt;p&gt;The second code block catches people who have done Azure RBAC a hundred times. &lt;strong&gt;Cosmos DB for NoSQL does not use standard Azure RBAC for data-plane access.&lt;/strong&gt; Contributor on the account buys management-plane rights and no ability to read a single document. You need a native Cosmos role assignment, identified by a fixed GUID, and two things about it will trip you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--principal-id&lt;/code&gt; is the service principal &lt;strong&gt;object&lt;/strong&gt; ID, while &lt;code&gt;azureClientId&lt;/code&gt; takes the client ID. Different GUIDs for the same identity, and the wrong one produces an assignment that exists and never applies.&lt;/li&gt;
&lt;li&gt;Data-plane role assignments &lt;a href="https://learn.microsoft.com/azure/cosmos-db/how-to-connect-role-based-access-control" rel="noopener noreferrer"&gt;cannot be managed in the Azure portal&lt;/a&gt;. CLI, PowerShell, or Bicep, or it does not happen.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pub/sub to Azure Service Bus
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dapr.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Component&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orderpubsub&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pubsub.azure.servicebus.topics&lt;/span&gt;       &lt;span class="c1"&gt;# Stable. The queues variant is Beta.&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
  &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;namespaceName&lt;/span&gt;                    &lt;span class="c1"&gt;# FQDN, and required for Entra ID&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sb-orders.servicebus.windows.net"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azureClientId&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8f3c1d2a-...-9b4e"&lt;/span&gt;
    &lt;span class="c1"&gt;# Leave the next two out in development and Dapr creates the topic and the&lt;/span&gt;
    &lt;span class="c1"&gt;# subscription on first use. Keep them for production, where the entities&lt;/span&gt;
    &lt;span class="c1"&gt;# come from your IaC and the sidecar never needs admin rights.&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;disableEntityManagement&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;consumerID&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders-fulfilment"&lt;/span&gt;
&lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;orders-api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;connectionString&lt;/code&gt; and &lt;code&gt;namespaceName&lt;/code&gt; are mutually exclusive: setting both fails the component with "connectionString and namespaceName cannot both be specified".&lt;/p&gt;

&lt;p&gt;Leave those last two fields out, publish once, and the portal shows you a subscription you never created, named after your application. Dapr creates one Service Bus subscription per topic per &lt;code&gt;consumerID&lt;/code&gt;, and &lt;strong&gt;&lt;code&gt;consumerID&lt;/code&gt; defaults to your Dapr app ID&lt;/strong&gt;. That default decides your delivery semantics without appearing in any config file: two applications with different app IDs each get their own subscription and both see every message, while two replicas of one application share a subscription and compete for it. Setting it explicitly, as the YAML above does, is how you make the choice visible to whoever reads it next.&lt;/p&gt;

&lt;p&gt;Auto-creation carries a permission requirement nobody advertises. Dapr creates topics and subscriptions through the Service Bus &lt;strong&gt;admin&lt;/strong&gt; client, which it constructs only when &lt;code&gt;disableEntityManagement&lt;/code&gt; is false. I want to be straight about where that requirement comes from: the Dapr docs never state it. It is derived from &lt;a href="https://github.com/dapr/components-contrib/blob/main/pubsub/azure/servicebus/topics/servicebus.go" rel="noopener noreferrer"&gt;the component source&lt;/a&gt; plus &lt;a href="https://learn.microsoft.com/azure/role-based-access-control/built-in-roles" rel="noopener noreferrer"&gt;Microsoft's role definitions&lt;/a&gt;. Creating an entity is a management operation, and neither Data Sender nor Data Receiver includes management rights, so as it ships the component needs &lt;strong&gt;Azure Service Bus Data Owner&lt;/strong&gt;. Verify that against your own namespace instead of taking it on trust, and know the shape of the failure: a subscriber whose sidecar cannot start, not a message that goes missing.&lt;/p&gt;

&lt;p&gt;The least-privilege path is to create the entities in your IaC, set &lt;code&gt;disableEntityManagement: "true"&lt;/code&gt;, and assign scoped Data Sender and Data Receiver. Two Learn caveats come with it: an assignment for a topic subscription needs effective scope over that topic subscription resource, which the portal cannot assign at, and propagation takes up to five minutes, long enough that your first test run afterwards fails for a reason unrelated to your configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Secrets to Azure Key Vault
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;secretstores.azure.keyvault&lt;/code&gt; has the shortest spec of the three: &lt;code&gt;vaultName&lt;/code&gt; and the shared auth fields. No key mode, no connection string mode, so Entra ID is the only way in. Dapr's own setup creates the vault with &lt;code&gt;--enable-rbac-authorization true&lt;/code&gt; and assigns &lt;strong&gt;Key Vault Secrets User&lt;/strong&gt;. Dapr's page describes that role as "Get secrets", which undersells it by one action: it also reads secret metadata, which is exactly what the &lt;code&gt;/bulk&lt;/code&gt; route needs in order to list names before it fetches values. No write, no delete, no keys, no certificates. Take the definition from Learn rather than from the Dapr page, and stop there.&lt;/p&gt;

&lt;p&gt;The naming rule is where two separate facts usually get merged into one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Azure constrains a secret name to alphanumerics and hyphens, so a configuration key like &lt;code&gt;ConnectionStrings:Orders&lt;/code&gt; cannot be a Key Vault secret name at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dapr does no name translation and no JSON flattening for the Key Vault store.&lt;/strong&gt; &lt;code&gt;GetSecret&lt;/code&gt; returns one entry keyed by the name you asked for, so JSON in the value is an opaque string your own code parses.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;Section--Name&lt;/code&gt; to &lt;code&gt;Section:Name&lt;/code&gt; convention that makes fact 1 survivable belongs to the ASP.NET Core Key Vault configuration provider, not to Dapr; through Dapr, a double hyphen is two hyphens in a name. The only Dapr secret store with a separator is the local file store, via &lt;code&gt;nestedSeparator&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Keeping connection strings out of component YAML closes the loop: a metadata entry takes &lt;code&gt;secretKeyRef&lt;/code&gt; in place of &lt;code&gt;value&lt;/code&gt;, and &lt;code&gt;auth.secretStore&lt;/code&gt; names the store as a sibling of &lt;code&gt;spec&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;connectionString&lt;/span&gt;
      &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders-servicebus-connection&lt;/span&gt;    &lt;span class="c1"&gt;# for Key Vault these two&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders-servicebus-connection&lt;/span&gt;     &lt;span class="c1"&gt;# must be identical&lt;/span&gt;
&lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;secretStore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orderssecrets&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two differ only for a multi-key store such as Kubernetes, where &lt;code&gt;name&lt;/code&gt; picks the secret and &lt;code&gt;key&lt;/code&gt; picks a field inside it.&lt;/p&gt;

&lt;p&gt;One asymmetry changes a runbook rather than a code file. Kubernetes secrets are re-read when they change; &lt;strong&gt;every other secret store, Key Vault included, is resolved once at component init&lt;/strong&gt;. Rotate a secret in the vault and the running sidecar keeps the old value until it restarts, or until you touch the component manifest and let hot reload pick it up. Rotation is a deployment step here, not a vault operation.&lt;/p&gt;

&lt;h3&gt;
  
  
  The auth chain that hides a misconfiguration
&lt;/h3&gt;

&lt;p&gt;Every Azure-backed component shares one credential model, and with none of its fields set Dapr walks a fixed chain where the first success wins: client credentials, client certificate, workload identity on AKS, SPIFFE, managed identity, and finally the Azure CLI.&lt;/p&gt;

&lt;p&gt;That last entry is the problem. On your laptop, where &lt;code&gt;az login&lt;/code&gt; succeeded weeks ago, a component with no identity configured authenticates through your own user account and the demo runs. Nothing in the logs suggests that the managed identity the component will use in Azure was never created, never assigned a Cosmos data-plane role, and never granted Key Vault Secrets User. You find all three at once, in a deployment, where the sidecar fails to initialize the component.&lt;/p&gt;

&lt;p&gt;The fix is one metadata entry. &lt;code&gt;azureAuthMethods: "managedidentity"&lt;/code&gt; restricts the chain to the listed methods, so the CLI credential is never tried and the local run fails the same way the deployment would. A failure you see at &lt;code&gt;dapr run&lt;/code&gt; is a config change; the same failure in a deployment is a rollback.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwity61fxmh8s2cekopgy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwity61fxmh8s2cekopgy.png" alt="Dapr building blocks mapped to Azure services" width="799" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it on your machine
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dapr init

&lt;span class="c"&gt;# Everything after -- is your application's own command line.&lt;/span&gt;
dapr run &lt;span class="nt"&gt;--app-id&lt;/span&gt; orders-api &lt;span class="nt"&gt;--app-port&lt;/span&gt; 5000 &lt;span class="nt"&gt;--resources-path&lt;/span&gt; ./components &lt;span class="nt"&gt;--&lt;/span&gt; dotnet run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dapr init&lt;/code&gt; leaves four containers running, not the three that older walkthroughs show: Redis as your default state store and message broker, Zipkin for traces, the &lt;strong&gt;placement&lt;/strong&gt; service for actors, and the &lt;strong&gt;scheduler&lt;/strong&gt; service for jobs, which only arrived in Dapr 1.14. The companion sample is a .NET 10 minimal API that exercises service invocation, state management, and pub/sub against a sidecar: &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/DaprDemo" rel="noopener noreferrer"&gt;DaprDemo in azure-functions-samples&lt;/a&gt;. Its README carries the full local loop, and it holds the local Redis component YAML next to the Azure one so you can see exactly what changes between them.&lt;/p&gt;

&lt;p&gt;Treat the CLI as a way to see the moving parts rather than as your inner loop: Part 4 replaces most of it with .NET Aspire, which models the sidecar and the components as part of the app host instead of as a YAML folder you remember to point at.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Azure Container Apps gives you, and what it takes away
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az containerapp create &lt;span class="nt"&gt;-n&lt;/span&gt; orders-api &lt;span class="nt"&gt;-g&lt;/span&gt; rg-orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--environment&lt;/span&gt; cae-orders &lt;span class="nt"&gt;--image&lt;/span&gt; acrorders.azurecr.io/orders-api:2026.08.21 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-dapr&lt;/span&gt; &lt;span class="nt"&gt;--dapr-app-id&lt;/span&gt; orders-api &lt;span class="nt"&gt;--dapr-app-port&lt;/span&gt; 8080 &lt;span class="nt"&gt;--dapr-app-protocol&lt;/span&gt; http
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One flag turns it on, the others describe your application, and the sidecar is there, patched and upgraded without you. No Helm chart, no control plane, no injector, no placement or scheduler service to keep alive. For most teams that is the whole argument for Container Apps over AKS.&lt;/p&gt;

&lt;p&gt;The Dapr settings are application-scope, so changing them restarts every existing revision; no new one is created. Components change shape too: in open-source Dapr a component belongs to an app or a namespace, while here it is an &lt;strong&gt;environment-level resource&lt;/strong&gt; deployed once and visible to everything in the environment, with YAML simplified to match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# statestore.yaml for Container Apps. The name is not in the file: it comes from&lt;/span&gt;
&lt;span class="c1"&gt;# az containerapp env dapr-component set --dapr-component-name statestore ...&lt;/span&gt;
&lt;span class="na"&gt;componentType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state.azure.cosmosdb&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;initTimeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;       &lt;span class="c1"&gt;# root level here, because there is no spec wrapper&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;url&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://acct-orders.documents.azure.com:443/"&lt;/span&gt;
  &lt;span class="c1"&gt;# database, collection, and azureClientId exactly as before&lt;/span&gt;
&lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;orders-api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;apiVersion&lt;/code&gt;, no &lt;code&gt;kind&lt;/code&gt;, no &lt;code&gt;spec&lt;/code&gt; wrapper, and &lt;code&gt;componentType&lt;/code&gt; where &lt;code&gt;spec.type&lt;/code&gt; used to be, which is why &lt;code&gt;initTimeout&lt;/code&gt; sits at the root here and on &lt;code&gt;spec&lt;/code&gt; in the open-source component earlier. The name comes from &lt;code&gt;--dapr-component-name&lt;/code&gt;, not the file, so a component copied out of a &lt;code&gt;docs.dapr.io&lt;/code&gt; tutorial is a rewrite, not a paste.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scopes&lt;/code&gt; carries more weight here, because every Dapr-enabled app in the environment loads every deployed component unless a scope says otherwise. And the values are &lt;strong&gt;Dapr app IDs, not container app names&lt;/strong&gt;: the same string often enough that nobody notices until a &lt;code&gt;--dapr-app-id&lt;/code&gt; diverges from the resource name and a component silently stops loading.&lt;/p&gt;

&lt;p&gt;Identity wiring loses a moving part, because the component has no identity of its own. It uses the managed identity of the container apps in its scope, so the Cosmos role assignment from earlier is made against the app.&lt;/p&gt;

&lt;p&gt;The constraints below are trades, not defects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Dapr Configuration spec is not available&lt;/strong&gt;, stated exactly once, in &lt;a href="https://learn.microsoft.com/azure/container-apps/dapr-overview" rel="noopener noreferrer"&gt;a bullet list&lt;/a&gt;, as "any capabilities that require use of the Dapr configuration spec". If you carry one limitation out of this article, carry that one. Everything in the open-source &lt;code&gt;Configuration&lt;/code&gt; resource goes at once: tracing sampling rates, access control policies between apps, API allowlists, feature flags, &lt;code&gt;spec.mtls&lt;/code&gt;, and &lt;code&gt;secrets.scopes&lt;/code&gt;. Tracing itself survives the cut, wired at the environment level instead through &lt;code&gt;daprAIConnectionString&lt;/code&gt; or an OpenTelemetry configuration with &lt;code&gt;includeDapr&lt;/code&gt; set; the sampling rate is the one knob with no documented replacement.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;secrets.scopes&lt;/code&gt; is the item to sit with, because it is the fix the secrets section pointed you at and it is not here. Your Key Vault store hands every secret in the vault to every app scoped to the component, and your only boundary is the vault itself and the RBAC on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;mTLS between sidecars is on, and there is no documented way off.&lt;/strong&gt; No Learn page says you cannot disable it; the conclusion follows from the exclusion above. Do not confuse it with the environment-level &lt;code&gt;peerAuthentication.mtls&lt;/code&gt; setting or with &lt;code&gt;ingress.clientCertificateMode&lt;/code&gt;, which sit at different layers and solve different problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot pin the Dapr version.&lt;/strong&gt; The only lever is timing, through planned maintenance. Version strings carry an Azure suffix such as &lt;code&gt;-msft.N&lt;/code&gt; whose numbers skip values, so watch &lt;a href="https://aka.ms/dapr-aca-updates" rel="noopener noreferrer"&gt;the update feed&lt;/a&gt; and not a version number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resiliency exists, in a different place than you would look for it, and still in preview.&lt;/strong&gt; &lt;code&gt;daprComponents/resiliencyPolicies&lt;/code&gt; carries the timeouts, HTTP retries, and circuit breakers, with independent &lt;code&gt;outboundPolicy&lt;/code&gt; (sidecar to component) and &lt;code&gt;inboundPolicy&lt;/code&gt; (sidecar to your app) settings. The portal exposes only timeout and retry, you restart your Dapr apps after applying one, and this is where the retry policy goes that has to exist before you enable &lt;code&gt;deadLetterTopic&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then the coverage gaps, which matter when you are choosing NuGet packages: the Dapr server extension, actor, and workflow SDK packages are not compatible with Azure Container Apps. &lt;strong&gt;Dapr is not supported for Container Apps jobs&lt;/strong&gt; either, so a scheduled job runs without a sidecar, and actor reminders require &lt;code&gt;minReplicas&lt;/code&gt; of at least 1, which takes scale-to-zero off the table.&lt;/p&gt;

&lt;p&gt;Which leaves the question from the top of the article where it started. Nobody has published what the sidecar costs inside a replica, so size it on the AKS figures and then measure your own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdzzw2zj3azr60duggz94.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdzzw2zj3azr60duggz94.png" alt="Open-source Dapr against Container Apps managed Dapr" width="800" height="492"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;What you buy is a code base with no vendor name in it: no &lt;code&gt;CosmosClient&lt;/code&gt;, no &lt;code&gt;ServiceBusClient&lt;/code&gt;, no &lt;code&gt;SecretClient&lt;/code&gt;, no connection string reaching your application at all. The bill arrives as a second process in every replica, an envelope you did not design, a key prefix you did not write, and a capability matrix to read before every component swap. Most of that price is paid in things you have to know rather than things you have to run, which is the part nobody puts on a slide.&lt;/p&gt;

&lt;p&gt;For a service with one queue and one table, that price buys nothing: a process, a component model, and a class of failure that exists only because Dapr is there, in exchange for portability you will never exercise.&lt;/p&gt;

&lt;p&gt;It starts paying when you have several services that call each other, share state, and need to prove which one is calling. Service discovery, mTLS, and retry policy stop being things you write and start being things the platform holds, and on Container Apps it is already holding the sidecar.&lt;/p&gt;

&lt;p&gt;Part 3 builds it: .NET 10 minimal APIs with service invocation and state management wired end to end, against the Cosmos DB and Service Bus components from this article rather than the Redis defaults &lt;code&gt;dapr init&lt;/code&gt; handed you.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>dapr</category>
      <category>dotnet</category>
      <category>cloudnative</category>
    </item>
    <item>
      <title>Why Move Beyond Azure Functions? The Enterprise Ceiling</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 14 Aug 2026 05:58:47 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/why-move-beyond-azure-functions-the-enterprise-ceiling-4mpp</link>
      <guid>https://dev.to/martin_oehlert/why-move-beyond-azure-functions-the-enterprise-ceiling-4mpp</guid>
      <description>&lt;p&gt;The case for leaving Azure Functions used to fit in one sentence, and Flex Consumption deleted it: on the plan Microsoft now recommends for new serverless work, the maximum execution timeout is unbounded. What that leaves behind is the harder question. Which limits survived the plan that was supposed to remove them, and are those the kind you engineer around or the kind you migrate away from?&lt;/p&gt;

&lt;p&gt;None of the survivors are about execution time. They are &lt;strong&gt;230 seconds for any HTTP response&lt;/strong&gt;, on every plan; &lt;strong&gt;4 GB of memory&lt;/strong&gt; across exactly three instance sizes; a scale-out rate that gets slower the busier your app already is; and a plan boundary that ends up deciding your team topology whether you wanted it to or not. Those are structural limits, and &lt;code&gt;functionTimeout&lt;/code&gt; moves none of them.&lt;/p&gt;

&lt;p&gt;This is written about apps that already work. Nothing below argues Functions was the wrong call, and most of the Functions code running in production today should stay exactly where it is. The argument starts at the point where the hosting model begins making architectural decisions on your behalf. Where this series goes when that happens is Azure Container Apps with Dapr and .NET Aspire; whether you are actually there yet is the thing worth settling first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan everyone is arguing about no longer exists
&lt;/h2&gt;

&lt;p&gt;If your mental model of Functions hosting is "Consumption or Premium," it is a generation out of date. The &lt;a href="https://learn.microsoft.com/azure/azure-functions/consumption-plan" rel="noopener noreferrer"&gt;Consumption plan&lt;/a&gt; now carries a legacy banner in its own documentation, &lt;a href="https://learn.microsoft.com/azure/azure-functions/flex-consumption-plan" rel="noopener noreferrer"&gt;Flex Consumption&lt;/a&gt; is the recommended serverless plan for new function apps, and &lt;strong&gt;Linux Consumption hosting retires on 30 September 2028&lt;/strong&gt;, receiving no new features or language versions before then. Apps running on Windows in a Consumption plan are explicitly not affected yet, so keep the Linux qualifier when you repeat this to your platform team.&lt;/p&gt;

&lt;p&gt;That changes the &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-scale#function-app-timeout-duration" rel="noopener noreferrer"&gt;maximum execution time&lt;/a&gt; picture completely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flex Consumption&lt;/strong&gt;: 30 minutes by default, unbounded maximum&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Premium (Elastic Premium)&lt;/strong&gt;: 30 minutes by default, unbounded maximum&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedicated (App Service)&lt;/strong&gt;: 30 minutes by default, unbounded with Always On&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container Apps&lt;/strong&gt;: 30 minutes by default, unbounded when min replicas is 1 or more&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consumption (legacy)&lt;/strong&gt;: 5 minutes by default, &lt;strong&gt;10 minutes&lt;/strong&gt; maximum&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The famous ten-minute wall sits on exactly one row of that list, and it is the row Microsoft is retiring. If you are still hitting it, the answer is a hosting change rather than an architecture change. Moving to Flex is not a plan toggle (there is no in-place migration, a point that comes back later), but it is a redeploy, not a rewrite.&lt;/p&gt;

&lt;p&gt;Everything after this section is about the limits that a redeploy will not fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The enterprise ceiling
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Timeouts that are unbounded until they aren't
&lt;/h3&gt;

&lt;p&gt;"Unbounded" carries two asterisks, and both of them bite in production.&lt;/p&gt;

&lt;p&gt;The first is that &lt;strong&gt;no HTTP response can take longer than 230 seconds&lt;/strong&gt;, on any plan, ever. That number is not a Functions setting: it is the Azure Load Balancer idle timeout sitting in front of your app, so &lt;code&gt;functionTimeout&lt;/code&gt; cannot raise it and no &lt;code&gt;host.json&lt;/code&gt; value will help. A function that runs for 25 minutes is fine. A function that runs for 25 minutes &lt;em&gt;and answers an HTTP request at the end of it&lt;/em&gt; is not, and the failure is asymmetric: the caller's connection is dropped while your function carries on executing, entirely unaware that nobody is listening.&lt;/p&gt;

&lt;p&gt;The documented way around it is the &lt;strong&gt;Durable async HTTP pattern&lt;/strong&gt;: accept the request, start the work, hand back somewhere to poll.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"StartMonthlyReport"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HttpResponseData&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthorizationLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Route&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"reports/monthly"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="n"&gt;HttpRequestData&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DurableClient&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;DurableTaskClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ReportRequest&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
        &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Report request body was empty."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ScheduleNewOrchestrationInstanceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MonthlyReportOrchestrator&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 202 Accepted plus statusQueryGetUri, terminatePostUri, and friends.&lt;/span&gt;
    &lt;span class="c1"&gt;// The HTTP request finishes in milliseconds. The report does not.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateCheckStatusResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works, and it is the right pattern. Count what it costs, though. You have added a Durable task hub with its own storage account and transaction bill, a status contract every caller now has to implement, a polling loop in each of those callers, and an orchestration whose replay semantics you have to test differently from ordinary code (the &lt;a href="https://dev.to/martin_oehlert/testing-durable-functions-and-production-realities-hmd"&gt;previous series&lt;/a&gt; spent a whole article on exactly that). A 40-second synchronous call became a distributed workflow because of a load balancer setting.&lt;/p&gt;

&lt;p&gt;The second asterisk is that unbounded execution is bounded anyway. Scale-in gives a running instance a &lt;strong&gt;60-minute grace period&lt;/strong&gt; on Flex and Premium; platform updates give it &lt;strong&gt;10 minutes&lt;/strong&gt;. A three-hour job is not safe on Functions even where the docs print the word "unbounded" next to your plan. Premium stacks three more interrupts on top: an idle timer stops the worker after 60 minutes with no new executions, scale-in can shut a worker down after the same 60 minutes, and a &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-premium-plan#longer-run-duration" rel="noopener noreferrer"&gt;slot swap terminates executions on both the source and target slots&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Read together, those give you long-running execution, not guaranteed-to-finish execution. If your workload cannot tolerate being killed mid-flight, you owe it checkpointing regardless of which plan you are on.&lt;/p&gt;

&lt;h3&gt;
  
  
  4 GB is the ceiling, and there are only three rungs
&lt;/h3&gt;

&lt;p&gt;Flex Consumption offers three &lt;a href="https://learn.microsoft.com/azure/azure-functions/flex-consumption-plan#instance-sizes" rel="noopener noreferrer"&gt;instance sizes&lt;/a&gt;. Not a range, not a slider. Three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;512 MB with 0.25 CPU cores&lt;/li&gt;
&lt;li&gt;2,048 MB with 1 core (the documented default recommendation)&lt;/li&gt;
&lt;li&gt;4,096 MB with 2 cores&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is a further 272 MB of platform buffer memory on top of each size, which you are not billed for. Nothing exists between 512 MB and 2,048 MB, so an app whose working set peaks at 700 MB buys the 2 GB rung and pays for the other 1.3 GB on every billed second of execution.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://learn.microsoft.com/azure/container-apps/containers#vcpu-and-memory-allocation-requirements" rel="noopener noreferrer"&gt;Container Apps allocates the same two resources in fixed pairs&lt;/a&gt;, from 0.25 vCPU / 0.5 GiB up to 4.0 vCPU / 8.0 GiB in quarter-core steps, with memory always at 2 GiB per vCPU. That is 16 rungs where Flex has three, and it goes twice as high on both axes. (A Consumption-only Container Apps environment caps at 2 cores / 4 GiB, which lands it back at parity with Flex, so the workload profile you choose matters.)&lt;/p&gt;

&lt;p&gt;The consequence is worth stating without hedging: &lt;strong&gt;a serverless workload that needs more than 4 GB has no Functions answer.&lt;/strong&gt; Your options are Premium EP2 (2 cores / 7 GB) or EP3 (4 cores / 14 GB), or Dedicated. All of them bill continuously whether or not anything is executing, which means the memory limit quietly converts a serverless cost model into an always-on one. That is not a scaling decision anymore. It is a budget decision, made for you by an instance-size table.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cold start is a scale-out problem, not an idle problem
&lt;/h3&gt;

&lt;p&gt;Almost every cold-start discussion frames it as an idle problem: nobody called your app for a while, the platform reclaimed the instance, the next caller pays for a fresh start. That framing is why cold start gets dismissed as a low-traffic concern, and it hides the case that actually breaks a latency SLA.&lt;/p&gt;

&lt;p&gt;Reframe it. &lt;strong&gt;Every instance the platform adds during a spike is a cold start.&lt;/strong&gt; A busy app that goes from 20 instances to 60 in ninety seconds just paid 40 cold starts, and it paid them at the exact moment its p99 was already under pressure.&lt;/p&gt;

&lt;p&gt;That would be tolerable if the platform added instances at a constant rate. It does not. Flex Consumption applies a documented &lt;a href="https://learn.microsoft.com/azure/azure-functions/flex-consumption-plan#scale-out-rate" rel="noopener noreferrer"&gt;scale-out rate curve&lt;/a&gt; where the per-interval allowance shrinks as your app grows:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When an app is running only a few instances, the allowance is at its largest... As an app grows to run more and more instances, the platform grants each additional batch more gradually.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Microsoft deliberately does not publish per-interval numbers and tells you not to design against them, so take the shape rather than the arithmetic: &lt;strong&gt;scale-out is fastest when you need it least, and most measured when you need it most.&lt;/strong&gt; That is the inverse of what a burst-shaped traffic profile asks for.&lt;/p&gt;

&lt;p&gt;There is one documented exemption. &lt;strong&gt;Always-ready instances bypass the on-demand scale-out rate entirely&lt;/strong&gt;, which makes them the supported way to pre-provision for a burst you can predict. The default is &lt;strong&gt;0&lt;/strong&gt;, so a Flex app that nobody has configured behaves like legacy Consumption at idle, and plenty of Flex apps in production have never been configured.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Two HTTP instances stay running ahead of the burst. They are exempt from the&lt;/span&gt;
&lt;span class="c"&gt;# on-demand scale-out rate curve, and they bill whether traffic arrives or not.&lt;/span&gt;
az functionapp create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; orders-api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; rg-orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--storage-account&lt;/span&gt; stordersapi &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--flexconsumption-location&lt;/span&gt; eastus &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--runtime&lt;/span&gt; dotnet-isolated &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-memory&lt;/span&gt; 2048 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--always-ready-instances&lt;/span&gt; &lt;span class="nv"&gt;http&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2

&lt;span class="c"&gt;# Push per-instance HTTP concurrency past the size-derived default of 16,&lt;/span&gt;
&lt;span class="c"&gt;# so each warm instance absorbs more of the spike before a new one is needed.&lt;/span&gt;
az functionapp scale config &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; orders-api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; rg-orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--trigger-type&lt;/span&gt; http &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--trigger-settings&lt;/span&gt; &lt;span class="nv"&gt;perInstanceConcurrency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;32
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two commands are the whole mitigation, and they are also the whole trade. Always-ready capacity has its own billing meter that runs continuously, and unlike on-demand execution it gets &lt;strong&gt;no free grant at all&lt;/strong&gt;. You are buying your way off the scale curve, which is a reasonable thing to do; it is just no longer scale-to-zero.&lt;/p&gt;

&lt;p&gt;Premium's equivalent works differently enough to be worth naming, because the word "prewarmed" oversells it. A &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-premium-plan#eliminate-cold-starts" rel="noopener noreferrer"&gt;prewarmed instance&lt;/a&gt; only becomes active once all your currently active instances are in use, so it is a buffer covering the &lt;em&gt;next&lt;/em&gt; instance, not a reserve that absorbs a burst. Always-ready is Premium's floor; prewarmed is a single step of headroom above it.&lt;/p&gt;

&lt;p&gt;One number is missing from all of this on purpose. Microsoft publishes no cold-start latency figure for any plan, and the independent measurements floating around are browser-timed, meaning they include connection setup and TLS on top of whatever the platform actually did. If you need a number for an SLA conversation, measure your own app on your own plan and label it as your measurement. Anything else you have read is somebody else's network.&lt;/p&gt;

&lt;h3&gt;
  
  
  The scaling gaps nobody reads until they page you
&lt;/h3&gt;

&lt;p&gt;The headline scale-out numbers look generous. Flex Consumption reaches 1,000 instances; Container Apps reaches 1,000 replicas, or 300 if you created the app from the portal; Premium reaches 100 on Windows and somewhere between 20 and 100 on Linux depending on the region; Dedicated manages 10 to 30, or 100 on an App Service Environment; legacy Consumption does 200 on Windows and 100 on Linux.&lt;/p&gt;

&lt;p&gt;Five things sit underneath those numbers, and each one has paged somebody.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The defaults are nowhere near the maximums.&lt;/strong&gt; Flex ships with a default max of 100, not 1,000. Container Apps ships with a default max of 10 replicas. Neither of those is the number in the marketing table, and neither changes because your traffic grew.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your configured maximum is a request, not a promise.&lt;/strong&gt; Flex Consumption enforces a &lt;a href="https://learn.microsoft.com/azure/azure-functions/flex-consumption-plan#regional-subscription-memory-quotas" rel="noopener noreferrer"&gt;regional subscription memory quota&lt;/a&gt; of &lt;strong&gt;250 cores and 512,000 MB per region per subscription&lt;/strong&gt;, shared across every Flex app you run there. Do the arithmetic on a single app configured at the recommended 2,048 MB size: 250 instances multiplied by 2,048 MB is 512,000 MB, and the quota is gone. One 512 MB app at its full 1,000 instances exhausts it just as completely. Your app can be sized correctly, configured correctly, and still stop scaling because a different team's Flex app in the same region and subscription got there first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Private endpoints cap you at 20 instances.&lt;/strong&gt; HTTP triggers behind a private endpoint scale no further than that, whatever your plan says. It is a networking decision, usually made by a different team, that silently becomes a throughput decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concurrency is derived from a size you may have picked for memory reasons.&lt;/strong&gt; &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-concurrency#http-trigger-concurrency" rel="noopener noreferrer"&gt;Default per-instance HTTP concurrency on Flex&lt;/a&gt; is 4 requests at 512 MB, 16 at 2,048 MB, and 32 at 4,096 MB. (Python is 1 at every size.) The tie between the two settings is the trap: set concurrency explicitly and it stops tracking instance size forever, so the next engineer who resizes the app for memory gets none of the concurrency change they expected.&lt;/p&gt;

&lt;p&gt;The last one is the least known and the most likely to invalidate a capacity plan. &lt;a href="https://learn.microsoft.com/azure/azure-functions/flex-consumption-plan#per-function-scaling" rel="noopener noreferrer"&gt;Per-function scaling&lt;/a&gt; is really per-&lt;em&gt;group&lt;/em&gt;: all your &lt;code&gt;http&lt;/code&gt; triggers scale together on shared instances, all your Event Grid &lt;code&gt;blob&lt;/code&gt; triggers scale together, all your &lt;code&gt;durable&lt;/code&gt; triggers scale together, and everything else scales individually. Your configured maximum then applies &lt;strong&gt;per group, not per app&lt;/strong&gt;. An app set to 100 instances with HTTP, blob, and Durable triggers is not budgeted for 100 instances. It is budgeted for up to 300, and the bill arrives shaped accordingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Organizational challenges
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The function app is the unit of scale, deployment, and blame
&lt;/h3&gt;

&lt;p&gt;Resource limits are negotiable. You can buy your way past most of them, and the previous section was mostly about what that costs. The limits in this section are different, because no amount of money moves them: they are shape, not size.&lt;/p&gt;

&lt;p&gt;Start with the one that surprises people who came to Functions from microservices. &lt;strong&gt;The function app, not the function, is the unit of scale.&lt;/strong&gt; On Consumption and Premium every function inside an app &lt;a href="https://learn.microsoft.com/azure/azure-functions/event-driven-scaling#runtime-scaling" rel="noopener noreferrer"&gt;shares the host instance&lt;/a&gt; and therefore shares its memory allocation. One PDF-rendering function that peaks at 3 GB drags every lightweight HTTP endpoint in the same app up onto a larger SKU with it, and those endpoints pay the higher rate on every instance, forever, for a function they never call.&lt;/p&gt;

&lt;p&gt;The same boundary is also the unit of deployment. Two teams sharing a function app ship together and roll back together, which means one team's bad Friday afternoon is the other team's incident. They also share a single &lt;code&gt;host.json&lt;/code&gt;, so logging levels, concurrency settings, retry policy, and extension bundle versions are one negotiation rather than two decisions.&lt;/p&gt;

&lt;p&gt;And it is the unit of blame, which is the part that shows up in retrospectives rather than architecture diagrams. When the app throttles, the telemetry says the app throttled. Attributing that to the function that consumed the instance is work you do by hand, after the fact, under time pressure.&lt;/p&gt;

&lt;p&gt;The obvious fix is to split the app, one per team. That fix has a bill attached, because splitting apps multiplies plans, and on Premium each new plan brings its own always-on floor. The organizational boundary you want costs real money to draw.&lt;/p&gt;

&lt;h3&gt;
  
  
  Premium couples every team through the plan
&lt;/h3&gt;

&lt;p&gt;Premium lets you put up to 100 function apps in one App Service Plan, which reads like a cost optimization and behaves like a coupling.&lt;/p&gt;

&lt;p&gt;The mechanic is in the &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-premium-plan#eliminate-cold-starts" rel="noopener noreferrer"&gt;Premium plan documentation&lt;/a&gt;, stated plainly. A plan's minimum instance count is the &lt;em&gt;maximum&lt;/em&gt; of its apps' always-ready counts. Put three apps in one plan, set two of them to 1 always-ready instance and the third to 5, and the plan minimum becomes 5: "the minimum number of instances for which your plan is billed."&lt;/p&gt;

&lt;p&gt;Follow that through to the org chart. The checkout team has a latency requirement and sets 5 always-ready instances to meet it. The reporting team, sharing the plan, is now billed against a floor of 5 as well, and nothing in the reporting team's own configuration explains why their number went up. Their &lt;code&gt;always-ready&lt;/code&gt; setting still reads 1. The cost lives in a resource neither team owns, driven by a value neither team can see from where they work.&lt;/p&gt;

&lt;p&gt;Flex Consumption inverts the whole arrangement: &lt;strong&gt;one function app per plan&lt;/strong&gt;, full stop. That kills the noisy-neighbour problem outright, and it kills the shared-floor billing with it. It also removes a capability in the same stroke, which is the subject of the next section.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deployment coupling and the doors that only open one way
&lt;/h3&gt;

&lt;p&gt;Flex Consumption has &lt;strong&gt;no deployment slots&lt;/strong&gt;. Premium gives you 3, legacy Consumption 2, Dedicated up to 20, and the recommended plan gives you none. If your zero-downtime story was "deploy to staging, warm it, swap," that story needs rewriting. The replacement is the &lt;a href="https://learn.microsoft.com/azure/azure-functions/flex-consumption-site-updates" rel="noopener noreferrer"&gt;rolling update site strategy&lt;/a&gt;, and two facts about it shape your release process before its behaviour does. It is generally available in East Asia, West Central US, North Central US, and West US 2 while the rollout to every other region continues, so check your region. And it can only be configured from Bicep or ARM templates, not the CLI, not the portal, and not VS Code, so whoever owns your deployment templates owns this decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is also no in-place migration into or out of Flex Consumption.&lt;/strong&gt; You create a new function app, redeploy, and cut over: DNS, keys, private endpoints, managed identity role assignments, the lot. That makes the plan choice a one-way door in both directions, which is an unusual property for a setting that looks like a dropdown.&lt;/p&gt;

&lt;p&gt;The third door is the one that turns a runtime concern into a deployment failure. &lt;strong&gt;Flex Consumption times out app initialization after 30 seconds&lt;/strong&gt;, the value is not configurable, and when you exceed it the symptom is a gRPC &lt;code&gt;System.TimeoutException&lt;/code&gt; rather than anything mentioning startup. Everything your &lt;code&gt;Program.cs&lt;/code&gt; does eagerly happens inside that window.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureFunctionsWebApplication&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Cheap to register, expensive to build. This one opens a blob connection and&lt;/span&gt;
&lt;span class="c1"&gt;// blocks the startup thread on it, inside the same 30-second budget.&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IOrderPricingCache&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;OrderPricingCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LoadFromBlobAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BlobServiceClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;())&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAwaiter&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;GetResult&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSqlServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetConnectionString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Orders"&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pricing cache that loads from blob storage in 8 seconds locally can take considerably longer from a cold instance against a throttled account, and there is no knob to buy more time. The fix is to make startup lazy (resolve the cache on first use, not at registration) rather than to make it faster, because "faster" has no floor you control. Discussion and workarounds are tracked in &lt;a href="https://github.com/Azure/azure-functions-host/issues/10482" rel="noopener noreferrer"&gt;azure-functions-host#10482&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;None of these three is a scaling limit. They are all deployment shape, and deployment shape is what your release process is built out of.&lt;/p&gt;

&lt;h3&gt;
  
  
  Debugging across triggers
&lt;/h3&gt;

&lt;p&gt;One order arrives on an HTTP trigger, drops a message that a queue trigger picks up to reserve inventory, and finishes inside a Durable orchestration that handles fulfilment. That is one business transaction and three execution models. Split those functions across apps for the team-boundary reasons above and you have also split them across Application Insights resources, at which point no single query can see the whole transaction.&lt;/p&gt;

&lt;p&gt;The Durable half of this is solvable, and the &lt;a href="https://dev.to/martin_oehlert/testing-durable-functions-and-production-realities-hmd"&gt;testing and monitoring article from the previous series&lt;/a&gt; covers the KQL for reconstructing one instance's history, including why &lt;code&gt;isReplay&lt;/code&gt; has to be in the filter. That technique still works here. It just stops at the app boundary, because correlation across a queue hop is plumbing you write yourself and keep writing yourself at every trigger transition.&lt;/p&gt;

&lt;h2&gt;
  
  
  When migration makes sense
&lt;/h2&gt;

&lt;p&gt;Every limit in the two sections above has a workaround, and most of those workarounds are defensible engineering. That is what makes the decision awkward. No single number tells you it is time to leave. What you get instead is a set of signals, and the useful question is not whether one of them is true but how many are true at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Signals you can check against your own system
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;You are chaining Durable Functions to dodge a limit rather than to model a workflow.&lt;/strong&gt; An orchestration that exists because the business process genuinely has steps, compensations, and a lifetime measured in hours is Durable Functions doing the job it was built for. An orchestration that exists because a 40-second call cannot answer over HTTP is the 230-second constraint wearing a workflow costume, and you keep paying for the disguise in task hubs, polling contracts, and replay-aware tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A single function is choosing the instance size for every function beside it.&lt;/strong&gt; If your size is set by one report renderer and the other eleven endpoints in the app are billed at that size on every instance they run on, the app boundary has stopped describing anything about your system except its worst-case memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your working set has passed 4 GB, or your dependency list has passed what you can install without a container.&lt;/strong&gt; Neither of those is a slope you can climb with configuration. There is no larger Flex instance to buy, and there is no package-install step in the sandbox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your p99 degrades during scale-out and always-ready capacity costs more than the latency is worth to the business.&lt;/strong&gt; Always-ready instances work. The question is whether the number of them you need has quietly turned a serverless app into a fleet you are sizing by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Team boundaries and function app boundaries have stopped lining up.&lt;/strong&gt; The split that would fix it is the one that multiplies plans and billed floors, which is why it keeps getting deferred to next quarter. When the org chart and the resource graph disagree, one of them wins, and it is rarely the one on the wiki.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need a capability the sandbox does not have.&lt;/strong&gt; A GPU, a sidecar, an init container, mTLS between services. These are not limits you tune around. They are absences.&lt;/p&gt;

&lt;p&gt;Counting matters more than any individual signal. One is a configuration problem: raise the instance size, buy always-ready capacity, split the app. Two or three usually mean a redesign inside Functions is still cheaper than leaving it. Once four or more are true, and the true ones are the structural kind (memory, boundaries, deployment shape), the choice is no longer between Functions and containers. It is between an accumulating pile of workarounds and one migration.&lt;/p&gt;

&lt;p&gt;One more item belongs on that scale, and it is the point the plan section left hanging. Because Flex Consumption supports no in-place migration in either direction, any plan change is already a new function app, a redeploy, and a full cutover: DNS, keys, private endpoints, and every managed identity role assignment. Price the two moves next to each other. Getting from legacy Consumption onto Flex costs a new resource and a cutover. Getting onto Container Apps costs a new resource, a cutover, and a Dockerfile. Most of the migration people hesitate over is work they committed to the moment Flex became the recommended plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  The case for staying
&lt;/h3&gt;

&lt;p&gt;Most Functions code in production should not move, and the reasons are better than inertia.&lt;/p&gt;

&lt;p&gt;Event-driven glue is what the programming model is for, and nothing about a container improves it. A queue trigger that validates a message and writes a row is a dozen lines with a binding and a hundred with a hosted service, a client, a lease policy, and a retry loop you now own and now have to test. Scheduled cleanup jobs, webhook receivers, and low-traffic internal APIs land in the same place: the trigger is the interesting part of the code, and Functions hands it to you already written.&lt;/p&gt;

&lt;p&gt;The free grants are not a rounding error either. Flex Consumption includes &lt;a href="https://azure.microsoft.com/en-us/pricing/details/functions/" rel="noopener noreferrer"&gt;250,000 executions and 100,000 GB-seconds&lt;/a&gt; per subscription per month. A 2 GB function that runs for two seconds burns 4 GB-seconds, so the grant absorbs 25,000 of those runs before execution time costs anything at all. Container Apps has a grant too, &lt;a href="https://learn.microsoft.com/azure/container-apps/billing#consumption-plan" rel="noopener noreferrer"&gt;180,000 vCPU-seconds, 360,000 GiB-seconds, and 2,000,000 requests&lt;/a&gt;, but a single replica pinned at one vCPU consumes 180,000 vCPU-seconds in about 50 hours. A grant sized against bursts and a grant sized against a continuously running replica behave very differently by the end of the month.&lt;/p&gt;

&lt;p&gt;Then there is duty cycle, which the next section turns into arithmetic. If your workload is spiky, short, and idle for most of the day, Functions is not a compromise you are tolerating. It is the correct answer, and a Container Apps replica held at one min replica would be a downgrade you paid extra for.&lt;/p&gt;

&lt;p&gt;The last reason never appears on a pricing page. A migration costs your team its next two quarters: a new deployment pipeline, new observability wiring, new runbooks, and a set of failure modes nobody has met yet. Spending that to escape a limit you could have configured your way past is the most expensive mistake available anywhere in this decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cost crossover, honestly
&lt;/h3&gt;

&lt;p&gt;The memory section left a claim unpaid. Every escape hatch above 4 GB bills continuously; so does every serious mitigation for scale-out latency; so does a Container Apps replica with min replicas set to 1. Once scale-to-zero has left both sides of the comparison, the argument stops being serverless against containers and becomes arithmetic about what fraction of the month your code is actually running.&lt;/p&gt;

&lt;p&gt;The anchor number first. &lt;strong&gt;One always-on Premium EP1 instance (1 core, 3.5 GB) costs about $157.71 a month in East US before a single execution runs&lt;/strong&gt;, at $0.173 per vCPU-hour and $0.0123 per GiB-hour across 730 hours. A Container Apps replica of comparable shape (1 vCPU / 2 GiB), billed at active rates every second of that month, is $78.84. A Flex always-ready baseline instance at 2,048 MB is $21.02, plus execution time on top of it. Those three numbers are most of the reason Premium keeps showing up in cost reviews.&lt;/p&gt;

&lt;p&gt;The crossover is more useful than any of them, because it tells you which side of the line your workload sits on. Here is the whole calculation, using pay-as-you-go rates pulled from the &lt;a href="https://learn.microsoft.com/rest/api/cost-management/retail-prices/azure-retail-prices" rel="noopener noreferrer"&gt;Azure Retail Prices API&lt;/a&gt; on 10 August 2026:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;East US
  Container Apps, 1 vCPU / 2 GiB, billed every second the replica exists:
    1 x $0.000024  +  2 x $0.000003   =  $0.000030 per second

  Flex Consumption, 2,048 MB instance, billed only while code executes:
    2 x $0.000026                     =  $0.000052 per second of execution

  Break-even duty cycle: 0.000030 / 0.000052 = 57.7%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Above roughly 58% duty cycle in East US, an always-on Container Apps replica costs less than Flex Consumption on demand for the same compute shape. Below it, scale-to-zero wins.&lt;/p&gt;

&lt;p&gt;Now run the identical calculation in another region, because this is the part that catches people out. Flex Consumption is priced the same in West Europe as in East US, to the sixth decimal place. Container Apps is not: vCPU is 42% more expensive there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;West Europe
  Container Apps, 1 vCPU / 2 GiB:
    1 x $0.000034  +  2 x $0.000004   =  $0.000042 per second

  Flex Consumption, 2,048 MB:         =  $0.000052 per second of execution

  Break-even duty cycle: 0.000042 / 0.000052 = 80.8%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same two services, the same workload shape, and the crossover moves from 58% to 81%. A quoted crossover figure with no region attached is not a rough number; it is a wrong one.&lt;/p&gt;

&lt;p&gt;Both of those percentages are my arithmetic, not a Microsoft-published figure, and they are only as good as their inputs. They use list pay-as-you-go rates for two regions on one date. They exclude the free grants, per-execution and per-request charges, storage, Application Insights, networking, and the Container Apps idle rate (vCPU idle is eight times cheaper than active in East US, while memory is billed at the same rate idle or active in both regions). They also assume the two platforms get equivalent throughput from a core, which is a property of your workload rather than of Azure. Treat 58% as a starting point for modelling your own numbers, not a constant to quote in a design review.&lt;/p&gt;

&lt;p&gt;One billing mechanic deserves its own sentence, because it wrecks the intuition behind these percentages for short functions. Flex Consumption's &lt;strong&gt;minimum billable execution period is 1,000 ms&lt;/strong&gt;, rounding up to the nearest 100 ms after that. A function that finishes in 40 ms bills as if it took a full second, which means an app made of very short executions is running at a much higher effective duty cycle than its telemetry suggests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cloud-native alternative
&lt;/h2&gt;

&lt;p&gt;Naming the destination matters less than naming what it fixes. Container Apps is the runtime, Dapr is the distributed-systems toolkit that replaces what bindings were doing, and Aspire is how you keep the local development loop from getting worse. Only the first of those is a hosting decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  Container Apps
&lt;/h3&gt;

&lt;p&gt;Take the limits from the first half of this article and check them off one at a time. Execution time is unbounded when min replicas is 1 or more. CPU and memory come in &lt;a href="https://learn.microsoft.com/azure/container-apps/containers#vcpu-and-memory-allocation-requirements" rel="noopener noreferrer"&gt;sixteen steps from 0.25 vCPU / 0.5 GiB to 4.0 vCPU / 8.0 GiB&lt;/a&gt; rather than three, so an app that peaks at 700 MB buys a 0.5 vCPU / 1 GiB pairing instead of the 2 GB rung. &lt;a href="https://learn.microsoft.com/azure/container-apps/scale-app#scale-definition" rel="noopener noreferrer"&gt;Scaling is KEDA&lt;/a&gt;, which means HTTP and TCP rules, CPU and memory rules, and any custom scaler in the KEDA catalogue, instead of platform heuristics you cannot inspect. Deployment safety comes from revisions with traffic splitting, which does blue/green and A/B rather than the single staging swap slots gave you. Custom images and GPU exist. Sidecars and init containers exist.&lt;/p&gt;

&lt;p&gt;Three things go the other way and should be said in the same breath. The 230-second HTTP wall does not vanish, it moves: &lt;a href="https://learn.microsoft.com/azure/container-apps/ingress-overview" rel="noopener noreferrer"&gt;Container Apps times a request out at its own ingress&lt;/a&gt; after 240 seconds, and the only supported way past that is premium ingress on a dedicated workload profile, where the limit becomes a configurable idle timeout of up to 30 minutes. Ten extra seconds is not an answer to the problem that sent you looking. An app that scales on CPU or memory load cannot scale to zero at all, so the KEDA flexibility buys you a floor in exactly the cases where you were hoping it would not. And the container image limit is 8 GB per replica on a Consumption workload profile, which is generous until somebody bakes a model into the image.&lt;/p&gt;

&lt;p&gt;The fact that de-risks this whole conversation belongs here rather than at the end: &lt;strong&gt;function apps run inside Container Apps.&lt;/strong&gt; You deploy the same app as a container app with &lt;code&gt;--kind functionapp&lt;/code&gt;, and the platform generates KEDA scale rules from the triggers you already have instead of making you rewrite them as scale rules by hand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The same function app, hosted on Container Apps. The triggers stay; the platform&lt;/span&gt;
&lt;span class="c"&gt;# translates them into KEDA scale rules unless allowScalingRuleOverride opts out.&lt;/span&gt;
az containerapp create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; orders-api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; rg-orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--environment&lt;/span&gt; cae-orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kind&lt;/span&gt; functionapp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--storage-account&lt;/span&gt; stordersapi &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; acrorders.azurecr.io/orders-api:2026.08.14 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ingress&lt;/span&gt; external &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-port&lt;/span&gt; 80 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--min-replicas&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-replicas&lt;/span&gt; 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes migration incremental rather than a cutover weekend, which matters more than any capability in the list above.&lt;/p&gt;

&lt;p&gt;It is not free of holes, and &lt;a href="https://learn.microsoft.com/azure/container-apps/functions-overview" rel="noopener noreferrer"&gt;the gaps are documented&lt;/a&gt;. Durable Functions autoscaling only works with the MSSQL or Durable Task Scheduler storage providers, so a Durable app on the default Azure Storage provider scales on the rules you write yourself. Blob-trigger autoscaling requires Event Grid. There is no autoscaling at all for the Azure Cache for Redis or Azure SQL triggers. Deployment slots do not exist, and neither do portal-generated Functions access keys. If your release process or your on-call tooling depends on any of those, the container-hosted version is a step backwards until you replace them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dapr
&lt;/h3&gt;

&lt;p&gt;Triggers and bindings are an excellent deal right up to the day you want the same message-publishing code to run inside a service, a console app, and a test. Then the binding turns out to be a property of the host rather than of your code, and there is no version of that code which runs anywhere else unchanged.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://learn.microsoft.com/azure/container-apps/dapr-overview" rel="noopener noreferrer"&gt;Dapr&lt;/a&gt; moves that contract into a sidecar your app talks to over HTTP or gRPC. Which broker, which state store, which secret store: all of it becomes component configuration attached to the environment instead of an attribute on a method signature. The building blocks that are GA in Container Apps cover service invocation, state management, publish/subscribe, bindings, actors, secrets, and configuration, which is most of what a bindings-heavy function app was doing, plus a virtual actor model that Functions has no answer for outside Durable entities.&lt;/p&gt;

&lt;p&gt;The sidecar is not everywhere, though. Dapr is not supported for Container Apps &lt;strong&gt;jobs&lt;/strong&gt;, so anything you were going to model as a scheduled job runs without it. Actor reminders are the other edge: they require min replicas of at least 1, which removes scale-to-zero from any actor-based design before you have written a line of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  .NET Aspire
&lt;/h3&gt;

&lt;p&gt;Aspire is the least dramatic piece here and the easiest to justify, because adopting it does not require you to have containerized anything. &lt;a href="https://learn.microsoft.com/azure/azure-functions/dotnet-aspire-integration" rel="noopener noreferrer"&gt;Aspire integrates with Azure Functions directly&lt;/a&gt;, so an existing function app can join an AppHost alongside the queue, the database, and whichever service you are building next, and a single F5 starts all of them with the connection wiring already generated. That makes Aspire the bridge into this stack rather than the reward at the end of it: you can run it against the architecture you have while you are still deciding whether to change it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1axh4eustzn61nbyeg6r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1axh4eustzn61nbyeg6r.png" alt="Azure Functions Flex Consumption vs Azure Container Apps: HTTP timeout, execution time, memory ceiling, size granularity, scale-out, deployment boundary, and cost model compared" width="799" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rest of this series does
&lt;/h2&gt;

&lt;p&gt;Seven articles follow this one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Introduction to Dapr for Azure Developers&lt;/strong&gt;: the building blocks, and how they map onto Azure services you already run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Building Your First Dapr + Web API Service&lt;/strong&gt;: .NET 10 Minimal APIs, service invocation, and state management end to end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;.NET Aspire: Orchestrating Cloud-Native Apps&lt;/strong&gt;: AppHost, service defaults, the local loop, and wiring Dapr into it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration Strategy: Incremental Transition Patterns&lt;/strong&gt;: strangler fig, Functions and containers running side by side, and the data migration nobody budgets for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise Architecture: Front Door, API Management, Container Apps&lt;/strong&gt;: traffic management, gateway policy, and the Managed Identity and RBAC patterns underneath.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure as Code: Terraform for Your New Stack&lt;/strong&gt;: module structure for Container Apps, networking, and CI/CD.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Complete Picture: A Reference Architecture&lt;/strong&gt;: the assembled stack, what it costs to operate, and what did not survive contact with production.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;The ceiling moved, and it moved somewhere harder to see. When the argument was about time you could tell the moment you hit it: a job died, the log said so, and the fix had a name. What replaced it never announces itself. An instance-size table quietly sets your monthly bill. A plan boundary decides which two teams share an incident, and whether you have a deployment slot determines what your release process is allowed to promise. None of those throw an exception, and all of them compound.&lt;/p&gt;

&lt;p&gt;That is also why the honest answer for most readers is to stay put. If your functions are event-driven glue running for a few hundred milliseconds a few thousand times a day, the free grant probably covers the bill, everything above is theoretical, and migrating would buy you a Dockerfile and a worse on-call rota. The case for moving arrives when the workarounds stop being individual decisions and start being an architecture, when what you built to stay inside the hosting model has grown larger than the thing it hosts.&lt;/p&gt;

&lt;p&gt;The rest of this series assumes you got there. Dapr for the building blocks, Aspire for the local loop, Container Apps for the runtime, Terraform to make it reproducible. None of it requires switching everything off first, which is the whole reason the migration article keeps Functions running alongside the containers rather than replacing them.&lt;/p&gt;

&lt;p&gt;When your Functions app hit its ceiling, was it a resource limit (memory, timeout, scale-out) or an organizational one (two teams stuck inside one deployment)?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>cloudnative</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Testing Durable Functions and Production Realities</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 17 Jul 2026 09:30:23 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/testing-durable-functions-and-production-realities-hmd</link>
      <guid>https://dev.to/martin_oehlert/testing-durable-functions-and-production-realities-hmd</guid>
      <description>&lt;p&gt;An orchestrator that calls three activities is not a function you can test like any other, and the reason is one word the rest of this article keeps returning to: replay. The runtime re-runs the orchestrator body from the top every time the instance wakes up, rebuilding progress from recorded history, so the question a test has to answer is not "did it return the right value once" but "does it return the right value every time the same code runs against the same history." That reframes two demands at once. A mocked activity has to hand back the same result on every call, and the only way to prove the real replay engine agrees with your mock is to run that engine.&lt;/p&gt;

&lt;p&gt;Those two demands split testing into two layers, and the split organizes everything below. &lt;strong&gt;Unit tests&lt;/strong&gt; mock the orchestration context, run in milliseconds, and prove your branch and aggregation logic; no engine runs and nothing replays. &lt;strong&gt;Integration tests&lt;/strong&gt; register the real orchestrator and activities with an in-memory host, so real replay, real serialization, and real activity execution all happen against your code. The root cause underneath both is determinism: an orchestrator body has to make the same decisions on every replay, and your tests either honour that constraint or they quietly lie about whether the workflow is correct.&lt;/p&gt;

&lt;p&gt;The code under test is the order workflow from the &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/DurableFunctionsDemo" rel="noopener noreferrer"&gt;DurableFunctionsDemo companion sample&lt;/a&gt; (isolated worker, .NET 10); the entity examples reuse the &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/DurableEntitiesDemo" rel="noopener noreferrer"&gt;DurableEntitiesDemo sample&lt;/a&gt; from the previous article. Tests use xUnit and Moq, the stack Microsoft's own Durable testing guidance assumes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why replay makes orchestrator tests different
&lt;/h2&gt;

&lt;p&gt;The tests target this orchestrator. It validates an order, creates it, sends a confirmation, and folds a trace id and a start time into the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderOrchestrator&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RunOrchestrator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OrchestrationTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()!;&lt;/span&gt;

    &lt;span class="c1"&gt;// Replay-safe substitutes: the runtime records these once and replays the&lt;/span&gt;
    &lt;span class="c1"&gt;// recorded value, so every replay sees the same timestamp and the same id.&lt;/span&gt;
    &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;startedUtc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CurrentUtcDateTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"Order validation failed"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SendConfirmationActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (trace &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;traceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, started &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;startedUtc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;O&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two properties of this function decide how it can be tested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It replays.&lt;/strong&gt; The runtime re-executes the body from the top after every awaited step completes, and again after a timer fires, an event arrives, or the host recycles. Awaits that already finished return their recorded results instead of running a second time, but the lines between them run again on each pass. The stretch from &lt;code&gt;GetInput&lt;/code&gt; to the final return may execute many times over one instance's life.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It must be deterministic.&lt;/strong&gt; Because every pass has to reach the same decisions, the body cannot read wall-clock time, mint random GUIDs, or touch I/O directly. That is why the sample reaches for &lt;code&gt;context.CurrentUtcDateTime&lt;/code&gt; and &lt;code&gt;context.NewGuid()&lt;/code&gt; instead of &lt;code&gt;DateTime.UtcNow&lt;/code&gt; and &lt;code&gt;Guid.NewGuid()&lt;/code&gt;: the runtime records each on the first pass and feeds the recorded value back on every later pass. A raw &lt;code&gt;DateTime.UtcNow&lt;/code&gt; would produce a fresh timestamp each replay and diverge the history, at which point the runtime raises &lt;code&gt;NonDeterministicOrchestrationException&lt;/code&gt;, though it does not detect every form of divergence.&lt;/p&gt;

&lt;p&gt;Both properties land on your tests. Since the body can run more than once, any value a test feeds in has to stay stable across calls; a mock that returns a new GUID or timestamp each time makes the same test pass or fail depending on how many replays happened. And because the return string folds in &lt;code&gt;traceId&lt;/code&gt; and &lt;code&gt;startedUtc&lt;/code&gt;, a test that stubs those with changing values has nothing stable to assert against. The fix is the discipline the orchestrator already follows: pin every value the body sees to a constant.&lt;/p&gt;

&lt;p&gt;Prevention comes before the test. The &lt;strong&gt;Durable Functions Roslyn Analyzer&lt;/strong&gt; flags the common determinism violations (&lt;code&gt;DateTime.Now&lt;/code&gt;, &lt;code&gt;Guid.NewGuid()&lt;/code&gt;, direct environment or I/O access) at edit time, so a whole class of replay bugs never reaches a test run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unit testing orchestrators: replay-safe mocks
&lt;/h2&gt;

&lt;p&gt;A unit test mocks &lt;code&gt;TaskOrchestrationContext&lt;/code&gt;, stubs each activity call, invokes the orchestrator method directly, and asserts on what it returns. The happy path runs a valid order all the way to its confirmation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Valid_order_runs_to_confirmation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"cust-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"sku-9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Mock&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()).&lt;/span&gt;&lt;span class="nf"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CurrentUtcDateTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTimeKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"00000000-0000-0000-0000-0000000000ab"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaskName&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAny&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(),&lt;/span&gt; &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAny&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaskOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;())).&lt;/span&gt;&lt;span class="nf"&gt;ReturnsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaskName&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAny&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(),&lt;/span&gt; &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAny&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaskOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;())).&lt;/span&gt;&lt;span class="nf"&gt;ReturnsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ORD-cust-1-sku-9"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaskName&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SendConfirmationActivity&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAny&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(),&lt;/span&gt; &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAny&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaskOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;())).&lt;/span&gt;&lt;span class="nf"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompletedTask&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;OrderOrchestrator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunOrchestrator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ORD-cust-1-sku-9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The matcher that trips everyone is the activity name. &lt;code&gt;CallActivityAsync&lt;/code&gt; does not take a &lt;code&gt;string&lt;/code&gt;; it takes a &lt;code&gt;TaskName&lt;/code&gt; struct, and the orchestrator's &lt;code&gt;nameof(ValidateOrderActivity)&lt;/code&gt; converts to one implicitly. Write &lt;code&gt;It.IsAny&amp;lt;string&amp;gt;()&lt;/code&gt; in the setup and it never matches, the mock returns &lt;code&gt;default&lt;/code&gt; (here &lt;code&gt;false&lt;/code&gt;), and the order "fails validation" for no visible reason. Match the struct instead: &lt;code&gt;It.Is&amp;lt;TaskName&amp;gt;(n =&amp;gt; n.Name == nameof(ValidateOrderActivity))&lt;/code&gt;. This is the single most common cause of a Durable orchestrator mock that "returns null." The third argument is &lt;code&gt;TaskOptions&lt;/code&gt;; set it up as &lt;code&gt;It.IsAny&amp;lt;TaskOptions&amp;gt;()&lt;/code&gt; even for calls that pass no options, because the overload Moq binds against always carries that parameter.&lt;/p&gt;

&lt;p&gt;The values you return matter as much as the names you match. &lt;code&gt;ReturnsAsync("ORD-cust-1-sku-9")&lt;/code&gt; is replay-safe: the same result on every call. Compare the version that reaches for real non-determinism.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Flaky: a fresh GUID on every access, so the returned string is never the same twice.&lt;/span&gt;
&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Guid.NewGuid&lt;/code&gt; here is a method group, so Moq invokes it on each call and hands back a new value. The orchestrator folds that GUID into its return, so the test has no stable string to assert on; worse, the same stub under the real replay engine (the integration host in the next section) records a different history on each pass and breaks the run outright. Pin it to a fixed value and both problems vanish. The rule is the orchestrator's own rule pointed back at the test: every value the body sees has to be a function of its input, not of when or how often it ran.&lt;/p&gt;

&lt;p&gt;The second thing this layer buys you is cheap branch coverage. Flip the validation result and assert the workflow short-circuits without ever creating an order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Invalid_order_stops_before_creation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cust-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Mock&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()).&lt;/span&gt;&lt;span class="nf"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CurrentUtcDateTime&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UnixEpoch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaskName&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAny&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(),&lt;/span&gt; &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAny&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaskOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;())).&lt;/span&gt;&lt;span class="nf"&gt;ReturnsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;OrderOrchestrator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunOrchestrator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Order validation failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaskName&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAny&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(),&lt;/span&gt; &lt;span class="n"&gt;It&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAny&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaskOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()),&lt;/span&gt; &lt;span class="n"&gt;Times&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Never&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Verify(..., Times.Never)&lt;/code&gt; is the assertion that earns its keep: it proves the guard actually skipped order creation, rather than the test landing on a matching string by luck.&lt;/p&gt;

&lt;p&gt;What this layer cannot do is run the real thing. The mocked context never replays, never serializes &lt;code&gt;OrderRequest&lt;/code&gt; into history, never executes an activity. A test can pass here while the deployed orchestrator diverges on replay or fails to round-trip its state. Closing that gap is the job of the in-memory host, and it is the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing activities and entities
&lt;/h2&gt;

&lt;p&gt;Activities are where the replay rules stop applying. An activity runs once per invocation, does the real I/O, and has no history to reconstruct, so it tests like any other method: call it, assert on what it returns. The sample's validation activity is a pure function of its input.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Validate_rejects_empty_sku&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;False&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cust-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;True&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cust-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"sku-9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No mock, no context, no harness. That is the reward for pushing logic into activities: the moment code leaves the orchestrator it becomes ordinary and testable.&lt;/p&gt;

&lt;p&gt;Real activities rarely stay pure. They call a database, an HTTP API, a blob container, and that dependency is exactly what you mock. Inject it through the constructor of an activity class (the isolated worker supports standard DI on &lt;code&gt;[Function]&lt;/code&gt; classes) and hand the test a fake. The sample registers &lt;code&gt;IPaymentGateway&lt;/code&gt; in &lt;code&gt;Program.cs&lt;/code&gt;, and the worker resolves it into the activity's constructor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PaymentActivities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IPaymentGateway&lt;/span&gt; &lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChargeCard&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ChargeCard&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;ActivityTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;OrderRequest&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ChargeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ChargeCard_delegates_to_gateway&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;gateway&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Mock&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IPaymentGateway&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ChargeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cust-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;ReturnsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;activities&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PaymentActivities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;True&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;activities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ChargeCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cust-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"sku-9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Entities test differently again, and the difference is worth pausing on. An entity is never replayed, so its operations run arbitrary code, which makes the logic itself the most ordinary thing in this article to check. The catch is access. On a class-based &lt;code&gt;TaskEntity&amp;lt;TState&amp;gt;&lt;/code&gt;, the &lt;code&gt;State&lt;/code&gt; property is &lt;code&gt;protected&lt;/code&gt; (verified against &lt;code&gt;Microsoft.DurableTask.Abstractions&lt;/code&gt; 1.24.1), so a test cannot reach in and seed it before calling an operation. A small test-only subclass exposes the seam.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TestableCounter&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Counter&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Counter.State is protected; widen it to the test so we can seed prior state.&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Seed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Add_accumulates_onto_existing_state&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;TestableCounter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Seed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Seed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Counter.Add&lt;/code&gt; is the previous article's &lt;code&gt;public void Add(int amount) =&amp;gt; this.State += amount;&lt;/code&gt;; the subclass only widens &lt;code&gt;State&lt;/code&gt; for the test.&lt;/p&gt;

&lt;p&gt;That same test names a design fact worth asserting on directly: &lt;code&gt;Add&lt;/code&gt; is not idempotent. Run it twice and you get 12 then 17, not 12 both times. Entity delivery is at-least-once, so an accumulating operation that runs twice on a redelivery double-counts. An operation you can prove idempotent (&lt;code&gt;Reset&lt;/code&gt;, or a &lt;code&gt;SetQuantity(x)&lt;/code&gt; that assigns instead of adds) survives redelivery unchanged, and that property deserves a test of its own.&lt;/p&gt;

&lt;p&gt;What none of these unit tests touch is serialization. Setting &lt;code&gt;Seed&lt;/code&gt; in memory never round-trips &lt;code&gt;State&lt;/code&gt; through &lt;code&gt;System.Text.Json&lt;/code&gt;, so the case-sensitivity and dictionary-key-casing traps from the previous article stay invisible here. Proving the state survives a real round-trip needs the in-memory host, which runs the actual serializer against your entity. That is where the next section goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integration testing with DurableTaskTestHost
&lt;/h2&gt;

&lt;p&gt;The mocked-context test proved the branch logic and never ran the engine. This test does the opposite: it starts the real orchestration engine in-process, schedules the workflow, and waits for it to finish. &lt;code&gt;DurableTaskTestHost&lt;/code&gt; (from &lt;code&gt;Microsoft.DurableTask.InProcessTestHost&lt;/code&gt;) hosts that engine in memory, with no Azure Storage, no emulator, and no sidecar to install.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Valid_order_completes_with_order_id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;DurableTaskTestHost&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;DurableTaskTestHost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Register the real orchestrator. The typed-input overload deserializes the&lt;/span&gt;
        &lt;span class="c1"&gt;// scheduled input to OrderRequest, so context.GetInput&amp;lt;OrderRequest&amp;gt;() inside&lt;/span&gt;
        &lt;span class="c1"&gt;// the orchestrator round-trips through the real serializer, exactly as in production.&lt;/span&gt;
        &lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddOrchestratorFunc&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderOrchestrator&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;OrderOrchestrator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunOrchestrator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="c1"&gt;// Register the production activity logic by name so CallActivityAsync executes it.&lt;/span&gt;
        &lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddActivityFunc&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddActivityFunc&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;CreateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddActivityFunc&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SendConfirmationActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* side-effect only */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ScheduleNewOrchestrationInstanceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderOrchestrator&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cust-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"sku-9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="n"&gt;OrchestrationMetadata&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitForInstanceCompletionAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;getInputsAndOutputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrchestrationRuntimeStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Completed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RuntimeStatus&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ORD-cust-1-sku-9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadOutputAs&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shape is register, schedule, wait, assert. &lt;code&gt;StartAsync&lt;/code&gt; takes a registration callback where you add the production orchestrator and activities; the standalone SDK registers them by name with &lt;code&gt;AddOrchestratorFunc&lt;/code&gt;/&lt;code&gt;AddActivityFunc&lt;/code&gt; (the Azure Functions worker discovers &lt;code&gt;[Function]&lt;/code&gt; methods for you at runtime, so this manual step is the test-side equivalent). &lt;code&gt;host.Client&lt;/code&gt; is a full &lt;code&gt;DurableTaskClient&lt;/code&gt;, the same type the production HTTP starter uses, so scheduling, status queries, terminate, and raise-event all work here too. &lt;code&gt;WaitForInstanceCompletionAsync(getInputsAndOutputs: true)&lt;/code&gt; blocks until the instance reaches a terminal state and pulls the output back so &lt;code&gt;ReadOutputAs&amp;lt;string&amp;gt;()&lt;/code&gt; can deserialize it.&lt;/p&gt;

&lt;p&gt;This is the layer that earns its runtime. The typed registration forces &lt;code&gt;OrderRequest&lt;/code&gt; through &lt;code&gt;System.Text.Json&lt;/code&gt; on the way in, &lt;code&gt;GetInput&amp;lt;OrderRequest&amp;gt;()&lt;/code&gt; deserializes it on the way out, and if a &lt;code&gt;[JsonPropertyName]&lt;/code&gt; or a case mismatch broke that round-trip the orchestration would land in &lt;code&gt;Failed&lt;/code&gt;, not &lt;code&gt;Completed&lt;/code&gt;. That is exactly the class of bug the mocked-context test in the previous section cannot see, because its &lt;code&gt;GetInput&lt;/code&gt; handed back the in-memory object it was told to return.&lt;/p&gt;

&lt;p&gt;The failure path is one assertion away. Pull that registration block into a &lt;code&gt;StartHostAsync()&lt;/code&gt; helper so both tests share it, then schedule an order with an empty SKU: the real &lt;code&gt;ValidateOrderActivity&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt;, the orchestrator short-circuits, and the instance completes with the validation message rather than throwing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Invalid_order_completes_with_validation_failure&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;DurableTaskTestHost&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;StartHostAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ScheduleNewOrchestrationInstanceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderOrchestrator&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cust-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="n"&gt;OrchestrationMetadata&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitForInstanceCompletionAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;getInputsAndOutputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrchestrationRuntimeStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Completed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RuntimeStatus&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Order validation failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadOutputAs&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two layers, not two choices. Unit tests stay for speed and branch coverage; &lt;code&gt;DurableTaskTestHost&lt;/code&gt; covers whole-workflow replay and serialization. One honest caveat on lineage: the classic DTFx &lt;code&gt;LocalOrchestrationService&lt;/code&gt; (in &lt;code&gt;DurableTask.Emulator&lt;/code&gt;) offered a similar in-memory host for the older API, but it is legacy. New isolated-worker code targets &lt;code&gt;DurableTaskTestHost&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage in production: Azure Storage vs Netherite
&lt;/h2&gt;

&lt;p&gt;The tests above ran against an in-memory engine. Production runs against a storage provider, and the provider you pick decides both your throughput ceiling and your bill. The question is narrower than "which is faster": it is at what volume the cheaper-per-event provider is worth its fixed cost floor.&lt;/p&gt;

&lt;p&gt;Start with where the money goes on the default provider. &lt;strong&gt;Azure Storage&lt;/strong&gt; drives execution through queues, records status and history in Tables, and distributes instances across workers with blobs and blob leases. Payloads over roughly 45 KB serialized are compressed and offloaded to a blob in &lt;code&gt;&amp;lt;taskhub&amp;gt;-largemessages&lt;/code&gt;, with a reference left in the row. The cost mechanic is the part worth internalizing: &lt;strong&gt;every orchestration event is Storage transactions&lt;/strong&gt;. Each activity call is a queue put and a queue get; each state change is a Table write. A three-activity orchestration generates well over a dozen billable transactions, and a fan-out that schedules two hundred activities multiplies that. At low volume the transaction bill is noise. At high fan-out it becomes the dominant line item, and per-transaction billing is what accumulates. For performance-sensitive apps, give Durable its own storage account rather than sharing &lt;code&gt;AzureWebJobsStorage&lt;/code&gt;, and prefer a general-purpose v1 account (v2 can bill Durable's transaction-heavy pattern at a higher rate).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Netherite&lt;/strong&gt; attacks that transaction bill directly. Built by Microsoft Research for high throughput, it can raise the ceiling by more than an order of magnitude. It does so by changing the storage shape: an Azure Event Hubs namespace moves messages between partitions, and a Storage account holds durable state through FASTER logs. Many small Storage transactions become batched Event Hubs traffic plus fewer, larger Storage operations. That trade buys better price-performance at scale, but it adds a &lt;strong&gt;fixed Event Hubs cost floor&lt;/strong&gt; (the provisioned throughput units) that a low-volume app pays whether or not it uses the capacity.&lt;/p&gt;

&lt;p&gt;The constraints are the part not to bury. Netherite is &lt;strong&gt;retiring: support ends March 31, 2028&lt;/strong&gt;. It is &lt;strong&gt;not supported on the Flex Consumption plan&lt;/strong&gt;, and it does &lt;strong&gt;not support identity-based connections&lt;/strong&gt;, so a managed-identity requirement rules it out. Switching providers does not migrate task-hub data; you start from a fresh, empty task hub. For those reasons Microsoft steers new high-throughput work toward the &lt;strong&gt;Durable Task Scheduler&lt;/strong&gt;, a managed, push-based gRPC backend with the highest throughput of the three, a built-in instance dashboard, managed-identity support, and no storage account to babysit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcucu3bv494cd3f3ezax9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcucu3bv494cd3f3ezax9.png" alt="Azure Storage vs Netherite: storage-provider comparison across support status, backend dependencies, throughput, scale-out nodes, cost shape, managed identity, Flex Consumption, local emulation, and best fit." width="800" height="441"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The decision rule is measure, do not guess. Netherite pays off only above the throughput threshold where its cheaper per-event handling outweighs the Event Hubs floor; below that line Azure Storage is both cheaper and simpler. There is no published ops-per-second figure to copy, because the crossover depends on your fan-out width, payload sizes, and event rate. Benchmark your own workload before switching an existing app, and for anything greenfield, evaluate the Durable Task Scheduler rather than adopting a backend with a retirement date.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring in Application Insights
&lt;/h2&gt;

&lt;p&gt;A test proves the workflow was correct when you shipped it. Monitoring answers the harder production question: which of ten thousand running instances failed last night, and where. Application Insights is the recommended surface, because the Durable extension already emits a &lt;strong&gt;tracking event&lt;/strong&gt; for every lifecycle transition (scheduled, started, awaited, completed, failed), and those events land in the &lt;code&gt;traces&lt;/code&gt; table where KQL can slice them.&lt;/p&gt;

&lt;p&gt;The first instinct in production is usually the wrong one: find every failed orchestration in a window. Each tracking event carries the orchestration's state in a custom dimension, so the filter is a single predicate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;traces
| where timestamp &amp;gt; ago(24h)
| where customDimensions["prop__runtimeStatus"] == "Failed"
| project timestamp,
          instanceId = customDimensions["prop__instanceId"],
          functionName = customDimensions["prop__functionName"],
          reason = customDimensions["prop__reason"]
| order by timestamp desc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you have an instance ID, the next query reconstructs what that one instance actually did. This is where a Durable-specific trap shows up. Because the orchestrator replays, the same log line appears many times, so a raw query returns the history several times over. Two dimensions clean it up: &lt;code&gt;isReplay&lt;/code&gt; filters out the replayed passes, and ordering by &lt;code&gt;sequenceNumber&lt;/code&gt; after &lt;code&gt;timestamp&lt;/code&gt; puts the events in execution order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;traces
| where customDimensions["prop__instanceId"] == "the-instance-id"
| where customDimensions["prop__isReplay"] == "False"
| project timestamp,
          functionType = customDimensions["prop__functionType"],
          functionName = customDimensions["prop__functionName"],
          state = customDimensions["prop__state"],
          sequenceNumber = toint(customDimensions["prop__sequenceNumber"])
| order by timestamp asc, sequenceNumber asc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sort by &lt;code&gt;timestamp&lt;/code&gt; first and &lt;code&gt;sequenceNumber&lt;/code&gt; second, in that order. &lt;code&gt;sequenceNumber&lt;/code&gt; resets to zero when the host restarts, so it disambiguates events inside one host lifetime but cannot order events across a restart on its own.&lt;/p&gt;

&lt;p&gt;A few configuration facts change what these queries can see. The relevant log category is &lt;code&gt;Host.Triggers.DurableTask&lt;/code&gt; in &lt;code&gt;host.json&lt;/code&gt;; its default level emits non-replay tracking events, and raising it to &lt;code&gt;Warning&lt;/code&gt; trims volume while &lt;code&gt;logReplayEvents: true&lt;/code&gt; turns the replay noise back on only when you are debugging a replay. Inputs and outputs are &lt;strong&gt;not&lt;/strong&gt; logged by default, for cost and PII reasons; only byte counts appear unless you opt in with &lt;code&gt;traceInputsAndOutputs&lt;/code&gt;. The Functions runtime also samples telemetry by default, which can silently drop lifecycle events during a burst, so tune sampling in &lt;code&gt;host.json&lt;/code&gt; if instances go missing from your results.&lt;/p&gt;

&lt;p&gt;For latency rather than failure, turn on &lt;strong&gt;distributed tracing V2&lt;/strong&gt;: set &lt;code&gt;distributedTracingEnabled: true&lt;/code&gt; and &lt;code&gt;version: "V2"&lt;/code&gt; under &lt;code&gt;durableTask.tracing&lt;/code&gt; in &lt;code&gt;host.json&lt;/code&gt; (isolated worker needs the extension at v1.4.0 or later). App Insights then draws an end-to-end Gantt chart in Transaction Search that correlates the orchestration with each activity and sub-orchestration, which is the fastest way to spot the one slow step in a long workflow.&lt;/p&gt;

&lt;p&gt;The thread back to the rest of this article is the log line inside the orchestrator. Because the body replays, a plain &lt;code&gt;logger.LogInformation(...)&lt;/code&gt; fires on every pass and floods the trace with duplicates. &lt;code&gt;context.CreateReplaySafeLogger("Name")&lt;/code&gt; suppresses the replayed emissions so each line logs once. The honest limit: it suppresses replay duplicates, not duplicates from a full re-execution (a host restart or an expired queue visibility timeout runs the body again from scratch, and those lines log again). Alongside App Insights, the &lt;strong&gt;Durable Functions Monitor&lt;/strong&gt; gives a UI over instance state, and the Roslyn Analyzer remains the cheapest observability of all: it catches the determinism bug at edit time, before there is anything to monitor.&lt;/p&gt;

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

&lt;p&gt;One word carried this whole article: replay. It is why a mocked activity has to return the same value on every call, why the mocked-context test can prove branch logic but never catches a serialization bug, and why the in-memory host has to exist at all. Testing Durable code is not one activity but a stack: fast mocked-context units for branch coverage, &lt;code&gt;DurableTaskTestHost&lt;/code&gt; for the replay and serialization the mocks cannot exercise, and plain method calls for activities and entities where replay never applied. Production sits on top of that stack, where the storage provider decides your bill and App Insights tracking events decide how fast you find the instance that broke.&lt;/p&gt;

&lt;p&gt;This was the last of five. The series started with triggers and the isolated worker, moved through orchestration patterns and fan-out, spent an article on entities and their at-least-once delivery, and ends here on proving the whole thing works before and after it ships. The connective tissue was always determinism: every constraint, from &lt;code&gt;context.NewGuid()&lt;/code&gt; to the replay-safe logger, exists so the runtime can re-run your code and get the same answer.&lt;/p&gt;

&lt;p&gt;When an orchestrator misbehaves in production, which do you reach for first: an App Insights tracking-event query to find where it failed, or a local &lt;code&gt;DurableTaskTestHost&lt;/code&gt; run to reproduce the replay on your machine?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>dotnet</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Durable Entities: Stateful Actors Inside Azure Functions</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 10 Jul 2026 06:10:38 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/durable-entities-stateful-actors-inside-azure-functions-54do</link>
      <guid>https://dev.to/martin_oehlert/durable-entities-stateful-actors-inside-azure-functions-54do</guid>
      <description>&lt;p&gt;Parts 1 to 3 gave you a workflow with a beginning and an end: an orchestration starts, runs its ordered steps, and completes. So what do you reach for when the thing you are modeling never ends, when it is just a piece of keyed state that a shopping cart, a game session, or a per-user counter keeps mutating over its whole lifetime, and every one of those mutations has to be race-free without you writing a single lock? That is the other half of Durable Functions. An &lt;strong&gt;entity&lt;/strong&gt; is a tiny stateful actor: long-lived, addressable by a string key, reactive to operations, and serialized so exactly one operation touches its state at a time. Orchestrations are code-as-workflow; entities are code-as-state, and the determinism rules that constrained your orchestrators do not apply here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defining an entity: from a counter to a cart
&lt;/h2&gt;

&lt;p&gt;Three shapes define an entity in the isolated worker, and the class-based one is the shape to reach for first. You derive from &lt;code&gt;TaskEntity&amp;lt;TState&amp;gt;&lt;/code&gt;, write your operations as public methods, and wire up a single &lt;code&gt;[Function]&lt;/code&gt; method with an &lt;code&gt;[EntityTrigger]&lt;/code&gt; to route incoming operations to them. Every code sample below is from the &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/DurableEntitiesDemo" rel="noopener noreferrer"&gt;companion sample&lt;/a&gt; (isolated worker, .NET 10). Here is the canonical counter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TaskEntity&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Reset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompletedTask&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;EntityTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskEntityDispatcher&lt;/span&gt; &lt;span class="n"&gt;dispatcher&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dispatcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DispatchAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things carry this. The persisted state is the inherited &lt;code&gt;this.State&lt;/code&gt; property, and only &lt;code&gt;State&lt;/code&gt; is serialized: any other field you hang off the class is scratch space that does not survive between operations. The &lt;strong&gt;operations&lt;/strong&gt; are the public methods (&lt;code&gt;Add&lt;/code&gt;, &lt;code&gt;Reset&lt;/code&gt;, &lt;code&gt;Get&lt;/code&gt;); each one reads or mutates &lt;code&gt;this.State&lt;/code&gt; and returns &lt;code&gt;void&lt;/code&gt;, a &lt;code&gt;Task&lt;/code&gt;, or a &lt;code&gt;Task&amp;lt;T&amp;gt;&lt;/code&gt; when it hands a value back. The &lt;code&gt;[Function(nameof(Counter))]&lt;/code&gt; method is the registration itself: &lt;code&gt;dispatcher.DispatchAsync&amp;lt;Counter&amp;gt;()&lt;/code&gt; takes the operation name off the incoming message and invokes the public method whose name matches. That trigger method is the whole wiring. There is no host.json opt-in and no attribute beyond &lt;code&gt;[Function]&lt;/code&gt; and &lt;code&gt;[EntityTrigger]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One naming rule crashes you at runtime if you miss it: never call the trigger method &lt;code&gt;RunAsync&lt;/code&gt;. &lt;code&gt;ITaskEntity&lt;/code&gt; already defines an instance &lt;code&gt;RunAsync(TaskEntityOperation)&lt;/code&gt;, confirmed against the compiled &lt;code&gt;Microsoft.DurableTask.Entities&lt;/code&gt; types, and the collision throws an ambiguous-match error the moment an operation dispatches. It compiles without complaint (your trigger is a static method, so C# sees no clash), which is exactly why the failure waits until runtime. Name the trigger &lt;code&gt;Run&lt;/code&gt;, or anything else, and the problem is gone.&lt;/p&gt;

&lt;p&gt;Two lifecycle operations come for free. Override &lt;code&gt;InitializeState&lt;/code&gt; to seed state on first access, before any operation has set it: &lt;code&gt;protected override int InitializeState(TaskEntityOperation entityOperation) =&amp;gt; 10;&lt;/code&gt; runs only while the state is still null. Deletion is setting state back to null; &lt;code&gt;TaskEntity&amp;lt;TState&amp;gt;&lt;/code&gt; hands you an implicit &lt;code&gt;Delete&lt;/code&gt; operation, and you override it by declaring your own &lt;code&gt;Delete()&lt;/code&gt; method that sets &lt;code&gt;this.State = null&lt;/code&gt; (which needs a nullable &lt;code&gt;TState&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;int&lt;/code&gt; is the smallest interesting state. Entities earn their keep when the state is a whole object. Model a shopping cart and the same shape holds: the operations become &lt;code&gt;AddItem&lt;/code&gt;, &lt;code&gt;RemoveItem&lt;/code&gt;, and &lt;code&gt;GetTotal&lt;/code&gt;, and &lt;code&gt;this.State&lt;/code&gt; becomes a collection of line items that lives across every operation the cart ever receives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;CartLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;UnitPrice&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ShoppingCart&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TaskEntity&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CartLine&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CartLine&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;InitializeState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TaskEntityOperation&lt;/span&gt; &lt;span class="n"&gt;entityOperation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;AddItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CartLine&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;RemoveItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RemoveAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sku&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetTotal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UnitPrice&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ShoppingCart&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;EntityTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskEntityDispatcher&lt;/span&gt; &lt;span class="n"&gt;dispatcher&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dispatcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DispatchAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ShoppingCart&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cart reads almost the same as the counter, and that is the point: the state grew from an &lt;code&gt;int&lt;/code&gt; to a &lt;code&gt;List&amp;lt;CartLine&amp;gt;&lt;/code&gt;, but the operation-per-method model and the single trigger did not change. &lt;code&gt;InitializeState&lt;/code&gt; returns an empty list on first access instead of relying on a null state, &lt;code&gt;AddItem&lt;/code&gt; and &lt;code&gt;RemoveItem&lt;/code&gt; each take their one argument, and &lt;code&gt;GetTotal&lt;/code&gt; returns a &lt;code&gt;Task&amp;lt;decimal&amp;gt;&lt;/code&gt; so an orchestration can call it for the value.&lt;/p&gt;

&lt;p&gt;Which brings up the rule that bites hardest once state stops being a primitive: &lt;strong&gt;entity state serializes with &lt;code&gt;System.Text.Json&lt;/code&gt;, not Newtonsoft.&lt;/strong&gt; Property names are case-sensitive on the way back in, a Newtonsoft &lt;code&gt;[JsonProperty]&lt;/code&gt; attribute is ignored (use &lt;code&gt;[JsonPropertyName]&lt;/code&gt;), and dictionary keys come out camelCased by default. The operation methods carry their own constraints: at most one argument each, no overloads, no generic type parameters, and every argument and return value has to be JSON-serializable. Design the state type and the operation signatures around those limits from the start, because a serialization mismatch shows up as silently-empty state after a round-trip, not a compile error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Talking to an entity: signal, call, and entity IDs
&lt;/h2&gt;

&lt;p&gt;Every entity is addressed by an ID, and you build one with &lt;code&gt;new EntityInstanceId(name, key)&lt;/code&gt;. The &lt;strong&gt;name&lt;/strong&gt; is the entity type, and it matches the entity function name (case-insensitively); the &lt;strong&gt;key&lt;/strong&gt; is the string that picks one instance out of every entity of that type, so a user id, an order id, or a GUID. &lt;code&gt;new EntityInstanceId(nameof(Counter), "myCounter")&lt;/code&gt; addresses the one counter keyed &lt;code&gt;myCounter&lt;/code&gt;, which internally resolves to the form &lt;code&gt;@Counter@myCounter&lt;/code&gt;. The ID is all you need to reach an instance, and if none with that ID exists yet, the first operation you send creates it.&lt;/p&gt;

&lt;p&gt;There are two ways to reach an entity, and the difference decides your whole design. A &lt;strong&gt;signal&lt;/strong&gt; is one-way and fire-and-forget: the task you await completes when the message is reliably enqueued, not when the entity has processed it, so there is no return value and no error to observe on the sending side. A &lt;strong&gt;call&lt;/strong&gt; is a two-way round-trip: the sender waits for the operation to run and gets back its result, or the exception it threw.&lt;/p&gt;

&lt;p&gt;The catch is that not every surface can do both. From a &lt;strong&gt;client&lt;/strong&gt; you can signal an entity and you can read its state, but you cannot call one for a return value.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;EntityInstanceId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"myCounter"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SignalEntityAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Add"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SignalEntityAsync&lt;/code&gt; enqueues an &lt;code&gt;Add&lt;/code&gt; operation carrying &lt;code&gt;5&lt;/code&gt; and returns the instant that message is durably queued. The increment happens later, on the entity's own turn. That is the entire vocabulary a client has for changing an entity: fire an operation and move on, with no value coming back.&lt;/p&gt;

&lt;p&gt;To pull a value out of an operation you have to be inside an &lt;strong&gt;orchestration&lt;/strong&gt;. The orchestrator's entity feature can both call and signal, and the call is the only place in Durable Functions where you get request/response with an entity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;EntityInstanceId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"myCounter"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallEntityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Get"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SignalEntityAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Add"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CallEntityAsync&amp;lt;int&amp;gt;(id, "Get")&lt;/code&gt; sends the &lt;code&gt;Get&lt;/code&gt; operation and awaits its return value, so &lt;code&gt;current&lt;/code&gt; holds the counter's actual state; the conditional &lt;code&gt;SignalEntityAsync&lt;/code&gt; then fires a one-way &lt;code&gt;Add&lt;/code&gt; only if it is under the threshold. Read the value with a call, change it with a signal. From inside an entity the vocabulary shrinks again: an entity can signal other entities but never call one and wait for a reply.&lt;/p&gt;

&lt;p&gt;Reading state without running an operation at all is the client's other move. &lt;code&gt;GetEntityAsync&amp;lt;T&amp;gt;&lt;/code&gt; hands back the entity's state without dispatching anything to it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;EntityMetadata&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetEntityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GetEntityAsync&amp;lt;int&amp;gt;&lt;/code&gt; returns an &lt;code&gt;EntityMetadata&amp;lt;int&amp;gt;?&lt;/code&gt; that is null when the entity has never been created, and the read does not create it (unlike a signal or a call). &lt;code&gt;entity.State&lt;/code&gt; is the typed value. The qualifier that matters is that this state is &lt;strong&gt;committed but can be stale&lt;/strong&gt;: the query hits the durable tracking store and returns the most recently persisted state, never a half-applied intermediate, but it can lag the entity's in-memory state. Only an orchestration sees an entity's live in-memory state; a client read is always a snapshot of the last commit.&lt;/p&gt;

&lt;p&gt;Two traps close out the surface. The first is API shape: in the isolated worker, entity access lives on &lt;code&gt;context.Entities.*&lt;/code&gt; and &lt;code&gt;client.Entities.*&lt;/code&gt;. The bare &lt;code&gt;context.SignalEntity(...)&lt;/code&gt; and &lt;code&gt;context.CallEntityAsync(...)&lt;/code&gt; forms belong to the in-process model, and reaching for them is the single most common isolated-migration error. The second follows straight from the signal semantics: because awaiting &lt;code&gt;SignalEntityAsync&lt;/code&gt; only means "enqueued," a client &lt;code&gt;GetEntityAsync&lt;/code&gt; fired right after a signal can still return the pre-signal state. Signal and then immediately read, and you should expect the old value; the write is on its way, not applied.&lt;/p&gt;

&lt;h2&gt;
  
  
  One operation at a time: the single-threaded guarantee
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Counter&lt;/code&gt; above has no lock around &lt;code&gt;Add&lt;/code&gt;, and that is not an oversight. It is the guarantee that makes an entity worth reaching for: a single entity runs its operations serially, one after another, and only one operation runs at a time for a given instance. Two callers can signal &lt;code&gt;Add&lt;/code&gt; at the same instant, and the runtime queues both and applies them in turn, so &lt;code&gt;this.State += amount&lt;/code&gt; never races another copy of itself. You get race-free updates with no lock, no semaphore, no compare-and-swap; serializing the operations is the platform's job, not yours.&lt;/p&gt;

&lt;p&gt;That serialization is scoped to one entity id, and this is what keeps it from being a bottleneck by default. The guarantee is per instance: &lt;code&gt;@Counter@userA&lt;/code&gt; and &lt;code&gt;@Counter@userB&lt;/code&gt; are scheduled independently, so a thousand distinct user counters make progress without ever queueing behind each other. This is the scale-out mechanism the actor model runs on, many entities each holding modest state, each serialized only against itself. Microsoft frames it as distributing work across many entities rather than promising that distinct ids run literally in parallel, so read it as "scheduled independently" and design for that, not for a parallelism guarantee.&lt;/p&gt;

&lt;p&gt;Underneath, delivery is reliable and in order: messages from one sender arrive FIFO. The honest qualifier is that on rare restart, scale, or crash paths an operation can be delivered more than once (at-least-once), so an &lt;code&gt;Add&lt;/code&gt; that runs twice should leave the same result as an &lt;code&gt;Add&lt;/code&gt; that runs once wherever you can arrange it. Idempotent operations are the design default here, not an edge-case hardening step you bolt on later.&lt;/p&gt;

&lt;p&gt;One property ties this back to the virtual-actor model from earlier: an entity has no create step. The first operation you signal or call to an id materializes it, and a read does not, which is why &lt;code&gt;GetEntityAsync&lt;/code&gt; on an id nothing has ever written to comes back null instead of a fresh zero-state instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  When one entity is a bottleneck: sharding for throughput
&lt;/h2&gt;

&lt;p&gt;The serial guarantee has a limit, and you hit it the moment one entity id gets popular. Point every increment in the system at a single &lt;code&gt;@Counter@global&lt;/code&gt; and you have rebuilt a single-threaded queue: every write lines up behind the one before it, and the entity works through them one at a time no matter how many workers you are running. There is no published per-entity operations-per-second figure to design against, so do not cite one, but the shape is clear enough. Throughput on one id is whatever one serialized entity can commit, and because entities prioritize durability over latency, they are a poor fit for latency-sensitive writes even before you hit that ceiling.&lt;/p&gt;

&lt;p&gt;The fix is to stop funneling. Replace the one id with N sub-entities, &lt;code&gt;@Counter@global-0&lt;/code&gt; through &lt;code&gt;@Counter@global-{N-1}&lt;/code&gt;, and each write picks one shard and signals only that shard. Up to N increments can now be in flight at once against N independently-scheduled entities, so the contention that a single id created spreads N ways. Reading the total means fanning out across every shard and summing what each one holds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;shard&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Shared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ShardCount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;shardId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;EntityInstanceId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;$"global-&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;shard&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SignalEntityAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shardId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Add"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;ShardCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;EntityInstanceId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;$"global-&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;EntityMetadata&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetEntityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The write path picks a shard (random here; a hash of the caller or plain round-robin works as well) and signals just that one, so no two writers are forced through the same entity unless they happen to land on the same index. The read path pays for that spread with a fan-out: it visits every shard and adds up the committed state.&lt;/p&gt;

&lt;p&gt;The cost lands on consistency, and it is worth stating rather than hiding. Each &lt;code&gt;GetEntityAsync&amp;lt;int&amp;gt;&lt;/code&gt; returns that shard's last committed state on its own, and under active writes some shards will have applied increments that others have not caught up to yet, so the summed total is eventually consistent: correct once the writes quiesce, approximate while they are in flight. If you need a total that is exact against a single instant while writes are still landing, you have to lock the shards together in a critical section, which serializes them again and hands back the throughput you sharded for in the first place. Shard when an approximate running total is acceptable, and keep a single id (or a database) for when the count has to be exact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Entities, orchestrations, or a database
&lt;/h2&gt;

&lt;p&gt;The decision that decides everything above is when to reach for an entity at all. Microsoft's documented "good for" list is short and specific: aggregating data from multiple sources, distributed locks and semaphores, and stateful objects like shopping carts or game sessions, with the counter as the canonical worked example. If what you are modeling is a named thing that accumulates state over its lifetime and reacts to updates, that is the entity's home ground.&lt;/p&gt;

&lt;p&gt;The first fork is entity versus orchestration, and it is the code-as-state and code-as-workflow line from the intro made concrete. An orchestration is a workflow: ordered steps, a defined start and end, deterministic replay, activity retries. An entity is long-lived keyed state with no predefined lifecycle, and because it is not replayed the way an orchestrator is, an operation can run any code, including the nondeterministic or long-running work that would be illegal inside an orchestrator. They are not rivals; you combine them, and the combination is the only place you get request/response with an entity, since a call has to originate in an orchestration.&lt;/p&gt;

&lt;p&gt;The second fork is entity versus a database row, and this is the one to be honest about, because the entity does not always win. The entity wins when the alternative is writing the locking code yourself: updates are serialized race-free, the state survives restarts, and the durable backend manages it with no connection pool or transaction of yours involved. The database wins on everything the entity was not built for: higher throughput on a single hot key, rich queries and joins, lower read latency, and state larger than the cap. Remember that a client read of an entity is committed-but-stale and that entities are not richly queryable, so a question like "every cart abandoned for more than an hour" is a query, and a query belongs to a database, not an entity.&lt;/p&gt;

&lt;p&gt;Some limits are worth naming before you commit. There is the serial-per-id throughput ceiling from the previous section; a maximum entity state of 1 MB on the Durable Task Scheduler backend (that cap is backend-specific, not a universal number, so check the one you run); durability prioritized over latency; and request/response available only from an orchestration. One thing that is not a documented limit: there is no rule against long-running work inside an operation. Current docs allow it. Keeping operations short is still worth doing, because a slow operation blocks the entity's whole queue and long work usually belongs in a child orchestration, but treat that as a design habit of your own, not a platform rule you can cite.&lt;/p&gt;

&lt;p&gt;When you do need to touch several entities atomically, the classic case being a bank transfer that debits one account and credits another with no observable state in between, you open a durable critical section from an orchestration over the entities you are locking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TransferOrchestrator&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TransferOrchestrator&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OrchestrationTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;TransferRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TransferRequest&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()!;&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;EntityInstanceId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BankAccount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;EntityInstanceId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BankAccount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;To&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LockEntitiesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;withdrew&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallEntityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
                &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BankAccount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Withdraw&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;withdrew&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CallEntityAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BankAccount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deposit&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signature is now pinned to the compiled SDK: &lt;code&gt;context.Entities.LockEntitiesAsync(...)&lt;/code&gt; has a &lt;code&gt;params EntityInstanceId[]&lt;/code&gt; overload (and an &lt;code&gt;IEnumerable&amp;lt;EntityInstanceId&amp;gt;&lt;/code&gt; one) and returns &lt;code&gt;Task&amp;lt;IAsyncDisposable&amp;gt;&lt;/code&gt;, so &lt;code&gt;await using (await context.Entities.LockEntitiesAsync(from, to))&lt;/code&gt; is the exact isolated shape. One note trips people up: entities and critical sections both work in the isolated worker, yet Microsoft's own orchestration docs still carry a stale line claiming critical sections are not available in isolated. That line is out of date; the mechanism moved to a renamed API, it did not disappear. Note too that a critical section gives you isolation, not a transaction: there is no automatic rollback, so any compensating undo on a mid-transfer failure is code you write.&lt;/p&gt;

&lt;p&gt;One deployment gotcha before you build on any of this: the MSSQL storage provider does not support entities in the isolated worker. If your task hub runs on MSSQL, entities are off the table until you move backends, and learning that now beats learning it from a failed deploy.&lt;/p&gt;

&lt;p&gt;There is also a client-side listing API for walking every entity of a type, for enumerating your shards or every open cart rather than addressing one by id. The isolated signature is confirmed against the compiled SDK: &lt;code&gt;client.Entities.GetAllEntitiesAsync&amp;lt;T&amp;gt;(EntityQuery)&lt;/code&gt; returns an &lt;code&gt;AsyncPageable&amp;lt;EntityMetadata&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; you enumerate with &lt;code&gt;await foreach&lt;/code&gt;. The &lt;code&gt;EntityQuery&lt;/code&gt; filter carries &lt;code&gt;InstanceIdStartsWith&lt;/code&gt; (matching the &lt;code&gt;@name@key&lt;/code&gt; id form), &lt;code&gt;IncludeState&lt;/code&gt;, and paging via &lt;code&gt;PageSize&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;EntityQuery&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;InstanceIdStartsWith&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"@&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ShoppingCart&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ToLowerInvariant&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s"&gt;@"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IncludeState&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EntityMetadata&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CartLine&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cart&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetAllEntitiesAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CartLine&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UnitPrice&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;You now own the decision the series has been circling. Orchestrations were code-as-workflow: a thing with a start, an end, and steps that replay deterministically. Entities are code-as-state: a named, addressable thing that outlives any single request, mutates across its lifetime, and hands you race-free updates without a line of locking code. The choice between them is not about which is newer or nicer; it is whether the thing you are modeling has a lifecycle or only a state.&lt;/p&gt;

&lt;p&gt;And when the answer is "only a state," one fork remains, and it is the one to sit with. Keyed state that mutates over a lifetime, needs serialized race-free updates, and can tolerate a committed-but-possibly-stale read points squarely at an entity. A need for high single-key throughput, rich queries, or strict read latency points just as squarely at a database row you manage yourself. Neither is the default answer. You pick on which set of constraints is actually yours.&lt;/p&gt;

&lt;p&gt;For the next piece of keyed state in your system, a per-user counter, a cart, a session: do you reach for a Durable Entity so the runtime serializes every update for you, or a database row you lock yourself?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>dotnet</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Human Interaction and External Events: Approval Workflows</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 03 Jul 2026 05:59:36 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/human-interaction-and-external-events-approval-workflows-3nkf</link>
      <guid>https://dev.to/martin_oehlert/human-interaction-and-external-events-approval-workflows-3nkf</guid>
      <description>&lt;p&gt;An expense report needs a manager's sign-off before the reimbursement goes out, and that manager might click approve in five minutes or come back from a trip in five days. The question Parts 1 and 2 never had to answer is how the workflow waits that long: a chain or a fan-out runs to completion in seconds, but an approval step has to suspend on a person and stay suspended without holding a thread or billing you for the idle time in between. Durable Functions answers the waiting half with a single &lt;code&gt;await&lt;/code&gt; that costs no compute while it is parked. The half most guides skip is the other one: the runtime hands you an instance ID when the workflow starts, and finding that one paused instance again when the approval finally arrives is your code's job, not the platform's.&lt;/p&gt;

&lt;h2&gt;
  
  
  WaitForExternalEvent pattern
&lt;/h2&gt;

&lt;p&gt;The mechanic at the center of every approval workflow is one line: &lt;code&gt;await context.WaitForExternalEvent&amp;lt;T&amp;gt;(eventName)&lt;/code&gt;. It suspends the orchestration until something outside the function raises an event with that name, and the typed payload it returns carries whatever the approver decided.&lt;/p&gt;

&lt;p&gt;Every code sample below is from the &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/DurableApprovalDemo" rel="noopener noreferrer"&gt;companion sample&lt;/a&gt; (isolated worker, .NET 10). Here is the expense-approval orchestrator: it parks on the wait, then hands the decision to an activity that settles or rejects the report.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Azure.Functions.Worker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.DurableTask&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ExpenseReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ReportId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Employee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Category&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ApprovalDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;DecisionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;Approved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Approver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;SettlementInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ExpenseReport&lt;/span&gt; &lt;span class="n"&gt;Report&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ApprovalDecision&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExpenseApprovalOrchestrator&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ExpenseApprovalOrchestrator&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OrchestrationTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExpenseReport&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()!;&lt;/span&gt;

        &lt;span class="c1"&gt;// Suspends here until an "ApprovalDecision" event is raised for this instance.&lt;/span&gt;
        &lt;span class="c1"&gt;// No thread is held and no compute is billed while the orchestration waits.&lt;/span&gt;
        &lt;span class="n"&gt;ApprovalDecision&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WaitForExternalEvent&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApprovalDecision&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"ApprovalDecision"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SettleExpenseActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SettlementInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;WaitForExternalEvent&amp;lt;ApprovalDecision&amp;gt;("ApprovalDecision")&lt;/code&gt; call is the whole pause. When the orchestrator reaches it, the runtime checkpoints the instance and unloads it; execution does not resume until an event named &lt;code&gt;ApprovalDecision&lt;/code&gt; is raised against this instance ID, at which point the raised JSON is deserialized into an &lt;code&gt;ApprovalDecision&lt;/code&gt; and the &lt;code&gt;await&lt;/code&gt; returns it. The first argument is the &lt;strong&gt;event name&lt;/strong&gt;, the contract both ends agree on, and matching is &lt;strong&gt;case-insensitive&lt;/strong&gt;: a wait on &lt;code&gt;"ApprovalDecision"&lt;/code&gt; is satisfied by an event raised as &lt;code&gt;"approvaldecision"&lt;/code&gt;. The type argument is the &lt;strong&gt;payload shape&lt;/strong&gt;; if the raised JSON cannot be converted to it, the wait throws rather than returning a half-filled object.&lt;/p&gt;

&lt;p&gt;The activity is an ordinary function that acts on the decision.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SettleExpenseActivity&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SettleExpenseActivity&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;ActivityTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;SettlementInput&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Approved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;$"Report &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReportId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; rejected by &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Approver&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Real work belongs here: queue the reimbursement, post to the ledger, notify the employee.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;$"Report &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReportId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; approved by &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Approver&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;C&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; scheduled for payment."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What makes this safe to wait on for days is that &lt;strong&gt;the suspension costs no compute&lt;/strong&gt;. Once the orchestrator yields at the wait, there is no thread blocked, no instance kept warm, nothing to bill. On the Consumption and Flex plans you pay for actual execution, not idle wait time, so an approval parked for a week is free until the event arrives. The wait is also &lt;strong&gt;replay-safe&lt;/strong&gt; in exactly the sense Part 1 set up: the pending event is part of the orchestration's durable state, so the worker can be stopped, scaled in, or recycled, and the instance is reawakened when the event shows up. An event that arrives early is not a problem either; if it is raised before the orchestrator has reached the wait, it is &lt;strong&gt;buffered&lt;/strong&gt; in the instance state and dispatched the moment the wait is reached, so a fast approver who beats the orchestration to the wait line does not lose their decision.&lt;/p&gt;

&lt;p&gt;One honest gotcha to design for up front: event delivery on the Azure Storage backend is &lt;strong&gt;at-least-once&lt;/strong&gt;, so a restart or scale event can deliver the same approval twice. That is why the payload here carries a &lt;code&gt;DecisionId&lt;/code&gt;. If the downstream activity is not naturally idempotent, dedupe on that ID so a duplicate delivery does not pay the same expense report twice. (The MSSQL provider consumes events transactionally and does not produce duplicates, but coding for at-least-once keeps the orchestrator portable across backends.)&lt;/p&gt;

&lt;p&gt;This wait is indefinite: nothing here ever gives up. A real approval workflow needs a deadline so a report that no one ever touches does not sit parked forever, and bounding the wait with a durable timer is the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approval endpoint design
&lt;/h2&gt;

&lt;p&gt;The orchestrator only knows how to wait. Two HTTP endpoints surround it: one to start the workflow and one to deliver the approver's answer. The start endpoint is the async HTTP pattern from Part 2, schedule the orchestration and hand back a status URL without blocking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Azure.Functions.Worker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Azure.Functions.Worker.Http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.DurableTask.Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StartExpenseApprovalClient&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StartExpenseApprovalClient&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HttpResponseData&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthorizationLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Route&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"expenses"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
            &lt;span class="n"&gt;HttpRequestData&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DurableClient&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;DurableTaskClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ExpenseReport&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExpenseReport&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
            &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Expense report body is required."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ScheduleNewOrchestrationInstanceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ExpenseApprovalOrchestrator&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 202 + management URLs; the orchestration is now parked on its WaitForExternalEvent.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateCheckStatusResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ScheduleNewOrchestrationInstanceAsync&lt;/code&gt; enqueues the orchestration and returns its &lt;strong&gt;instance ID&lt;/strong&gt; without waiting for it to run, and &lt;code&gt;CreateCheckStatusResponseAsync&lt;/code&gt; builds the &lt;strong&gt;HTTP 202&lt;/strong&gt; response: a &lt;code&gt;Location&lt;/code&gt; header pointing at the status-query endpoint and a JSON body of management URLs for the instance. By the time the caller has that 202, the orchestration is already parked on its &lt;code&gt;WaitForExternalEvent&lt;/code&gt;. Hold on to that returned &lt;code&gt;instanceId&lt;/code&gt;; it is the only handle that reaches the parked instance, and the approval endpoint is useless without it.&lt;/p&gt;

&lt;p&gt;The approval endpoint is the other half. It reads the manager's decision and raises the event the orchestrator is blocked on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ApprovalRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;Approved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Approver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SubmitApprovalClient&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SubmitApprovalClient&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HttpResponseData&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthorizationLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Route&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"expenses/{instanceId}/decision"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
            &lt;span class="n"&gt;HttpRequestData&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DurableClient&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;DurableTaskClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ApprovalRequest&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApprovalRequest&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
            &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Approval body is required."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ApprovalDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;DecisionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"N"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;Approved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Approved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Approver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Approver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Raises the event the orchestrator is waiting on. Returns when the event is&lt;/span&gt;
        &lt;span class="c1"&gt;// enqueued, not when the orchestrator has consumed it.&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RaiseEventAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ApprovalDecision"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateCheckStatusResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RaiseEventAsync(instanceId, "ApprovalDecision", decision)&lt;/code&gt; is the mirror image of the wait. The event name has to match the orchestrator's wait name (again, case-insensitively), and the &lt;code&gt;decision&lt;/code&gt; object is JSON-serialized and deserialized into the orchestrator's &lt;code&gt;ApprovalDecision&lt;/code&gt; on the other side. The returned task completes when the event is &lt;strong&gt;enqueued&lt;/strong&gt;, not when the orchestrator wakes up and consumes it, so a 202 here means "your decision is on its way," not "the report is settled." The caller polls the status URL to see the workflow reach &lt;code&gt;Completed&lt;/code&gt;. Generating the &lt;code&gt;DecisionId&lt;/code&gt; server-side is what makes the earlier dedupe work: it gives every raised decision a stable identity even if the platform delivers it twice.&lt;/p&gt;

&lt;p&gt;There are two ways to raise this event, and the choice is the reason this endpoint exists at all. The 202 body from the start call already contains a &lt;code&gt;sendEventPostUri&lt;/code&gt;, the &lt;strong&gt;built-in raise-event API&lt;/strong&gt;: a caller can POST the decision straight to &lt;code&gt;.../instances/{instanceId}/raiseEvent/ApprovalDecision&lt;/code&gt; with no code from you. It is the quickest path, and it returns useful status codes (404 for an unknown instance, 410 for one that already finished). What it does not give you is a place to put your own concerns. The custom endpoint above exists so you can own the &lt;strong&gt;route, authentication, validation, and audit&lt;/strong&gt;: check that this approver is allowed to sign off this report, record who decided and when, reject a malformed body before it ever reaches the orchestration. If none of that matters for your case, the built-in webhook is less to maintain.&lt;/p&gt;

&lt;p&gt;The honest gotcha lives in the failure mode of the SDK call. A bare &lt;code&gt;RaiseEventAsync&lt;/code&gt; to a &lt;strong&gt;completed or non-existent instance is silently discarded&lt;/strong&gt;: no exception, no error, nothing. Raise &lt;code&gt;ApprovalDecision&lt;/code&gt; against a stale or mistyped instance ID and the call returns happily while the event evaporates, and the approver sees a success they did not get. If you need to tell an approver that the workflow they are signing off no longer exists, pre-check with &lt;code&gt;GetInstanceAsync&lt;/code&gt; and inspect the runtime status before raising, or use the built-in HTTP API and surface its 404 and 410 to the caller. The silent path is convenient until the instance ID is wrong.&lt;/p&gt;

&lt;p&gt;Which raises the question this endpoint quietly assumes away: it takes &lt;code&gt;instanceId&lt;/code&gt; from the route as if the caller already knows it. Where that ID comes from, how the approval link in the manager's email ends up carrying the right one, and how you avoid losing it, is its own problem, and the section on instance ID storage is where it gets solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timeout and escalation
&lt;/h2&gt;

&lt;p&gt;A wait that never gives up is a workflow you cannot operate. The previous section left the orchestration parked on &lt;code&gt;WaitForExternalEvent&lt;/code&gt; with no exit; a report that no manager ever touches stays &lt;code&gt;Running&lt;/code&gt; until someone terminates it by hand. The fix is to &lt;strong&gt;race the event against a durable timer&lt;/strong&gt; and let whichever finishes first decide the outcome.&lt;/p&gt;

&lt;p&gt;Start the timer and the wait, then &lt;code&gt;await Task.WhenAny&lt;/code&gt; on the pair. The orchestration wakes on the first of the two to complete.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExpenseApprovalOrchestrator&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ExpenseApprovalOrchestrator&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OrchestrationTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExpenseReport&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()!;&lt;/span&gt;

        &lt;span class="c1"&gt;// Deadline comes off the orchestration clock, NOT DateTime.UtcNow. Part 1's&lt;/span&gt;
        &lt;span class="c1"&gt;// determinism rule: every replay must compute the same instant, and&lt;/span&gt;
        &lt;span class="c1"&gt;// CurrentUtcDateTime is frozen to the original execution time on replay.&lt;/span&gt;
        &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CurrentUtcDateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CancellationTokenSource&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApprovalDecision&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;approvalTask&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WaitForExternalEvent&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApprovalDecision&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"ApprovalDecision"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="n"&gt;timeoutTask&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateTimer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="n"&gt;winner&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WhenAny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approvalTask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeoutTask&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;winner&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;approvalTask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// The approval landed first. Cancel the timer before moving on.&lt;/span&gt;
            &lt;span class="n"&gt;cts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Cancel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
                &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SettleExpenseActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SettlementInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;approvalTask&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// The timer won: nobody decided in time. Fail closed by auto-rejecting.&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;timedOut&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ApprovalDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;DecisionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$"timeout-&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReportId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Approved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Approver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"system (timeout)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$"No decision by &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SettleExpenseActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SettlementInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedOut&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details carry the weight here. The first is the &lt;strong&gt;deadline source&lt;/strong&gt;. &lt;code&gt;context.CurrentUtcDateTime&lt;/code&gt; is the replay-safe clock from Part 1: on the first execution it is the real time, and on every replay after a checkpoint it returns that same original value, so &lt;code&gt;AddDays(3)&lt;/code&gt; resolves to one fixed instant no matter how many times the orchestrator re-runs. Use &lt;code&gt;DateTime.UtcNow&lt;/code&gt; instead and the deadline drifts forward on every replay, which breaks determinism and can move the timer past where it should have fired.&lt;/p&gt;

&lt;p&gt;The second is the &lt;strong&gt;&lt;code&gt;cts.Cancel()&lt;/code&gt; on the approval branch&lt;/strong&gt;, and it is easy to read as optional cleanup when it is not. &lt;code&gt;CreateTimer&lt;/code&gt; registers a durable timer in the instance state, and the framework will not let an orchestration reach &lt;code&gt;Completed&lt;/code&gt; while a timer it created is still outstanding. Skip the cancel and your approved report settles its activity, then sits in &lt;code&gt;Running&lt;/code&gt; for the rest of the three days until the abandoned timer finally fires. Cancelling the token does not abort anything in flight; it tells the runtime to drop the pending timer so the orchestrator can finish now. The &lt;code&gt;using&lt;/code&gt; on the &lt;code&gt;CancellationTokenSource&lt;/code&gt; disposes it when the method exits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail-closed is a choice, not a rule.&lt;/strong&gt; Auto-rejecting on timeout is the conservative default: an expense nobody approved should not be paid. The richer variant is to &lt;strong&gt;escalate&lt;/strong&gt; rather than reject. Instead of returning, the timeout branch notifies a second approver (in the expense case, the manager's manager) and waits again with a fresh timer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Escalation variant for the timeout branch: re-notify, then wait once more.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NotifyEscalationApproverActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;escalationCts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CancellationTokenSource&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApprovalDecision&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;escalatedApproval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WaitForExternalEvent&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApprovalDecision&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"ApprovalDecision"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="n"&gt;escalationTimeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateTimer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CurrentUtcDateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;escalationCts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WhenAny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;escalatedApproval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;escalationTimeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;escalatedApproval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;escalationCts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Cancel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SettleExpenseActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SettlementInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;escalatedApproval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Still nothing after the second window: now fail closed.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each escalation round is the same race with a new deadline and a new &lt;code&gt;CancellationTokenSource&lt;/code&gt;, so the same two rules apply every time: derive the deadline from &lt;code&gt;CurrentUtcDateTime&lt;/code&gt;, cancel the timer when the event wins. You can wrap the round in a loop to escalate up a chain of approvers, but give it a hard ceiling; an unbounded escalation loop is the indefinite wait you just removed, wearing a different hat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instance ID storage patterns
&lt;/h2&gt;

&lt;p&gt;The approval endpoint took &lt;code&gt;instanceId&lt;/code&gt; straight from its route, as if the caller already had it. The start endpoint, meanwhile, returned that ID inside a 202 and then dropped it. So when the manager opens an email three days later and clicks approve, what fills in the &lt;code&gt;{instanceId}&lt;/code&gt; segment of &lt;code&gt;expenses/{instanceId}/decision&lt;/code&gt;? This is the half the intro flagged: Durable Functions hands you the instance ID at start time and keeps &lt;strong&gt;no index from a business entity to its instance ID&lt;/strong&gt;. Mapping report &lt;code&gt;R-2048&lt;/code&gt; back to the orchestration that is waiting on it is your code's job, and there are three ways to do it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1: an external store keyed by the business entity.&lt;/strong&gt; Write a row at start time (&lt;code&gt;ReportId&lt;/code&gt; to &lt;code&gt;instanceId&lt;/code&gt;) into whatever database or lookup service you already run, then read it back in the approval endpoint. This is the production default. It is authoritative and queryable, it supports many runs mapping to one entity and full history, and it survives instance purging. The cost is that you now own a second piece of state: an extra write on start, an extra read on approval, and the consistency between that row and the orchestration is yours to keep (make the write idempotent or transactional with the start so you cannot end up with a row pointing at an instance that never scheduled, or an instance with no row).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2: make the instance ID the business key, and store nothing.&lt;/strong&gt; &lt;code&gt;ScheduleNewOrchestrationInstanceAsync&lt;/code&gt; lets you supply the ID instead of taking an autogenerated GUID, via &lt;code&gt;StartOrchestrationOptions.InstanceId&lt;/code&gt;. If the ID &lt;em&gt;is&lt;/em&gt; the report key, the approval endpoint reconstructs it from the route with no lookup at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ScheduleNewOrchestrationInstanceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ExpenseApprovalOrchestrator&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;StartOrchestrationOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;InstanceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"expense-&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReportId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the email link is just &lt;code&gt;expenses/expense-{ReportId}/decision&lt;/code&gt;, built from data you already have, and there is no table to keep in sync. The constraints are real, though, and the runtime enforces them unevenly across storage providers, so honor them regardless: the ID must be &lt;strong&gt;unique within the task hub&lt;/strong&gt;, &lt;strong&gt;1 to 100 characters&lt;/strong&gt;, must &lt;strong&gt;not start with &lt;code&gt;@&lt;/code&gt;&lt;/strong&gt;, and must &lt;strong&gt;not contain &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;\&lt;/code&gt;, &lt;code&gt;#&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;, or control characters&lt;/strong&gt;. Raw GUIDs are fine; emails and file paths usually need encoding first. The mapping is strictly &lt;strong&gt;one-to-one&lt;/strong&gt;, so this fits short, naturally unique, single-run keys and not much else. A report ID like &lt;code&gt;expense-R-2048&lt;/code&gt; qualifies; a customer who can file many reports does not.&lt;/p&gt;

&lt;p&gt;Option 2 also inherits a gotcha worth stating plainly: scheduling an instance ID that already exists is &lt;strong&gt;not&lt;/strong&gt; a safe atomic create-if-absent. The documented pattern is check-then-start (call &lt;code&gt;GetInstanceAsync&lt;/code&gt;, inspect &lt;code&gt;RuntimeStatus&lt;/code&gt;, and start only if the instance is missing or in a terminal state), and Microsoft flags a concurrency race even then: two requests for the same key can both pass the check and both report success while only one orchestration actually runs. If a duplicate submit must never double-schedule, you need a lock outside Durable Functions, which starts to erode the "store nothing" advantage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 3: a Durable Entity as a registry.&lt;/strong&gt; Entities are supported in the .NET isolated worker (the "not in isolated" caveat you may have read refers to in-orchestration critical sections, not entities), their operations run serially so there is no intra-entity race, and the index stays inside Durable Functions instead of a separate database. Treat this as viable but unproven: no official guidance endorses an entity as the business-key index, entities favor durability over latency so client reads can be stale, and routing every lookup through one hot registry entity serializes all traffic into a throughput bottleneck. Reach for it only if you have a specific reason to avoid an external store.&lt;/p&gt;

&lt;p&gt;One temptation to rule out: the query APIs are not a reverse lookup. &lt;code&gt;GetInstanceAsync&lt;/code&gt; needs the ID you are trying to find. &lt;code&gt;GetAllInstancesAsync(OrchestrationQuery)&lt;/code&gt; filters only on runtime status, time range, and orchestration name, with no predicate for an arbitrary business key, so finding &lt;code&gt;R-2048&lt;/code&gt; that way means scanning every instance client-side, an O(n) walk that degrades as history grows and breaks once completed instances are purged. Custom status (&lt;code&gt;SetCustomStatus&lt;/code&gt;) is for surfacing progress and caps at 16 KB; tags are queryable only in the scheduler dashboard, not from code. None of them is a point lookup. A business key to instance ID mapping always comes back to an app-owned index (option 1) or a derivable ID (option 2).&lt;/p&gt;

&lt;p&gt;For the expense workflow the call is short. If a report ID is already a clean single-run key, option 2 removes a whole moving part: the email link encodes the ID and there is nothing to persist or reconcile. The moment you need many approvals per report, audit history, or a key that does not survive the ID character rules, option 1's extra row pays for itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The pause is the easy half. &lt;code&gt;WaitForExternalEvent&lt;/code&gt; suspends for days on a single &lt;code&gt;await&lt;/code&gt; and bills you nothing while it waits, and a &lt;code&gt;CreateTimer&lt;/code&gt; race keeps that wait from becoming a leak. The half that decides whether this works in production is the one most walkthroughs skip: the instance ID is a handle the platform hands you once and never indexes, so reuniting a parked workflow with the human who finally answers it is a design decision you make on purpose, at start time. Get that wrong and the approver clicks a link that raises an event into the void.&lt;/p&gt;

&lt;p&gt;So make the call deliberately on your next approval workflow: do you persist the instance ID in an external table keyed by the business entity, or derive it from the business key so there is nothing to store?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>dotnet</category>
      <category>azurefunctions</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Fan-Out/Fan-In and the Async HTTP Pattern</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 26 Jun 2026 05:00:00 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/fan-outfan-in-and-the-async-http-pattern-194j</link>
      <guid>https://dev.to/martin_oehlert/fan-outfan-in-and-the-async-http-pattern-194j</guid>
      <description>&lt;p&gt;You have 500 order line items to process and no ordering dependency between them, so the question is why the workflow takes as long as 500 activities run back to back when nothing forces them to. The chaining pattern from Part 1 awaits each activity before scheduling the next, which is exactly right when step two needs step one's output and exactly wasteful when the items are independent. The fix is fan-out/fan-in: schedule all the activities at once, then aggregate the results, and the only real trick is doing it in a way that survives the orchestrator replaying. There is also a one-character version of this code that compiles, runs, and silently throws the parallelism away, which is the bug this article spends the most time on.&lt;/p&gt;

&lt;h2&gt;
  
  
  When sequential processing isn't enough
&lt;/h2&gt;

&lt;p&gt;Chaining is the pattern you reach for when the steps form a line: validate the order, then create it, then send the confirmation, each one feeding the next. The &lt;code&gt;await&lt;/code&gt; between steps is load-bearing there, because &lt;code&gt;CreateOrderActivity&lt;/code&gt; genuinely cannot start until &lt;code&gt;ValidateOrderActivity&lt;/code&gt; has returned. That dependency is what makes the sequence correct.&lt;/p&gt;

&lt;p&gt;Now change the shape of the work. A batch order arrives with 500 line items, and each one needs the same per-item processing: check stock, price it, reserve inventory. No line item depends on any other. If you write that as a chain, awaiting each item's activity before scheduling the next, the total wall-clock time is the sum of all 500 activities. At a rough sequential ceiling of about 5 activities per second on a single instance, that batch takes roughly a minute and a half, and every second of it is one item waiting on the item before it for no reason.&lt;/p&gt;

&lt;p&gt;The independence is the whole point. When the items have no ordering relationship, the latency you actually care about is not the sum of the activities but the slowest single one, because there is nothing stopping them from running at the same time. Fan out across the available workers and the same batch finishes in the time of its longest item, plus a little aggregation overhead. The documented fan-out throughput target is around 100 activities per second per instance, an order of magnitude over the sequential figure, and that gap is entirely the difference between running the work in series and running it in parallel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fan-out/fan-in&lt;/strong&gt; is the orchestration pattern for that situation: fan out by scheduling many activities at once, fan in by waiting for all of them and collecting the results. The replay engine from Part 1 is what makes it safe, and the next section shows the exact shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fan-out/fan-in with Task.WhenAll
&lt;/h2&gt;

&lt;p&gt;The shape has two halves. &lt;strong&gt;Fan-out&lt;/strong&gt; means projecting your inputs into activity calls and collecting the tasks without awaiting any of them. &lt;strong&gt;Fan-in&lt;/strong&gt; means a single &lt;code&gt;await Task.WhenAll(tasks)&lt;/code&gt; that completes once every activity has finished, handing you the results.&lt;/p&gt;

&lt;p&gt;Every code sample below is from the &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/DurableFanOutDemo" rel="noopener noreferrer"&gt;companion sample&lt;/a&gt; (isolated worker, .NET 10). Here is the batch processor as an orchestrator, with an aggregation step at the end.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frtrpkouc79azlnw0s1ey.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frtrpkouc79azlnw0s1ey.png" alt="Fan-out/fan-in shape: the orchestrator schedules every ProcessItemActivity at once without awaiting, a single Task.WhenAll waits for all of them and returns the results in input order, then SummarizeBatchActivity aggregates them into a BatchSummary." width="712" height="464"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Azure.Functions.Worker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.DurableTask&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;Reserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;LineTotal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;BatchSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Processed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Reserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessBatchOrchestrator&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessBatchOrchestrator&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BatchSummary&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OrchestrationTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()!;&lt;/span&gt;

        &lt;span class="c1"&gt;// Fan-out: schedule every item at once, collect the tasks unawaited.&lt;/span&gt;
        &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;[]&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
            &lt;span class="p"&gt;[..&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
                &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessItemActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;))];&lt;/span&gt;

        &lt;span class="c1"&gt;// Fan-in: one await blocks until all of them complete.&lt;/span&gt;
        &lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WhenAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Optional: hand the collected results to a final aggregation activity.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BatchSummary&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SummarizeBatchActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fan-out is the collection expression. &lt;code&gt;items.Select(...)&lt;/code&gt; projects each &lt;code&gt;OrderItem&lt;/code&gt; into a &lt;code&gt;CallActivityAsync&amp;lt;OrderResult&amp;gt;&lt;/code&gt; call, and because nothing awaits those calls, each one schedules an activity and returns its &lt;code&gt;Task&amp;lt;OrderResult&amp;gt;&lt;/code&gt; immediately. The &lt;code&gt;[.. ...]&lt;/code&gt; spread materializes them into a &lt;code&gt;Task&amp;lt;OrderResult&amp;gt;[]&lt;/code&gt;. After that line, all 500 activities are scheduled; none has been waited on.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;await Task.WhenAll(tasks)&lt;/code&gt; is the fan-in. It returns a single task that completes only when every task in the array has completed, and its result is an &lt;code&gt;OrderResult[]&lt;/code&gt;. The element order matches the order of the &lt;code&gt;tasks&lt;/code&gt; array, not the order the activities happened to finish in, so &lt;code&gt;results[0]&lt;/code&gt; is always the result for &lt;code&gt;items[0]&lt;/code&gt; regardless of which line item processed fastest. That positional guarantee is standard &lt;code&gt;Task.WhenAll&amp;lt;TResult&amp;gt;&lt;/code&gt; behavior, and it is why you can return the array directly without sorting or correlating anything back to its input.&lt;/p&gt;

&lt;p&gt;The activity itself is an ordinary function doing the per-item work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessItemActivity&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessItemActivity&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;OrderResult&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;ActivityTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;OrderItem&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Real work and I/O belong here: check stock, price, reserve inventory.&lt;/span&gt;
        &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;lineTotal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;9.99m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lineTotal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Task.WhenAll&lt;/code&gt; is not one of the nondeterministic APIs banned inside an orchestrator. It is replay-safe, and the reason traces straight back to Part 1's replay engine. The durable calls it aggregates are the replay-checkpointed operations: the runtime records each scheduled &lt;code&gt;CallActivityAsync&lt;/code&gt; in the History table the moment the orchestrator yields, and records each result as it arrives. On a replay the orchestrator reaches the same fan-out line, the framework sees those activities already scheduled (and any already completed), and reconstructs the same task array from history rather than re-running the work. The whole parallel batch therefore survives a host recycle the same way a chain does: the activities run across multiple workers concurrently, and the end-to-end execution is resilient to the orchestrator being unloaded and replayed.&lt;/p&gt;

&lt;p&gt;One behavior to know before you ship this. When several activities fail, &lt;code&gt;await Task.WhenAll(tasks)&lt;/code&gt; throws only the &lt;strong&gt;first&lt;/strong&gt; exception, even though more than one task faulted. If you need to see every failure (to log all of them, or decide based on how many failed) inspect the &lt;code&gt;Exception&lt;/code&gt; property on the task that &lt;code&gt;Task.WhenAll&lt;/code&gt; returns, which holds the full &lt;code&gt;AggregateException&lt;/code&gt; with one inner exception per faulted activity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop mistake (and the fix)
&lt;/h2&gt;

&lt;p&gt;The previous section showed the correct shape. This section is about the version that looks just as correct, compiles cleanly, runs without error, and quietly runs everything in series anyway. It is the most common fan-out bug, and it is worth being able to spot in a code review on sight, because nothing else will flag it for you.&lt;/p&gt;

&lt;p&gt;Here is the broken orchestrator. A reviewer skimming it sees a loop over the items, an activity call per item, and a list of results. It reads like a fan-out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BROKEN: awaiting inside the loop runs the activities one after another.&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessBatchOrchestrator&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OrchestrationTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()!;&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessItemActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// awaits before scheduling the next&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[..&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;await&lt;/code&gt; is in the wrong place. Each iteration calls &lt;code&gt;CallActivityAsync&lt;/code&gt;, then &lt;code&gt;await&lt;/code&gt; suspends the orchestrator until that one activity returns, and only after the result is added to the list does the loop come back around to schedule the next item. So the activities are scheduled one at a time, each waiting on the one before it. This is the chaining pattern wearing a loop, and it has the chaining pattern's latency: the sum of all 500 activities, back at roughly 5 per second. There is no compiler warning, no runtime exception, no log line that looks wrong. The only symptom is that a batch that should take a couple of seconds takes a minute and a half, and you usually only notice once the batch sizes grow in production.&lt;/p&gt;

&lt;p&gt;The fix is to move the &lt;code&gt;await&lt;/code&gt; out of the loop. Schedule everything first, collect the tasks, then fan in with a single &lt;code&gt;Task.WhenAll&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// FIXED: collect every task first, then fan in with one Task.WhenAll.&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessBatchOrchestrator&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OrchestrationTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()!;&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;[]&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="p"&gt;[..&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessItemActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;))];&lt;/span&gt;   &lt;span class="c1"&gt;// scheduled, not awaited&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WhenAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                &lt;span class="c1"&gt;// all run in parallel&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference is whether &lt;code&gt;await&lt;/code&gt; sits on each individual call or once on the whole array. In the broken version every &lt;code&gt;CallActivityAsync&lt;/code&gt; is awaited the instant it is made, which serializes the schedule-and-wait. In the fixed version the &lt;code&gt;Select&lt;/code&gt; schedules all of them without awaiting any, and the single &lt;code&gt;await Task.WhenAll(tasks)&lt;/code&gt; is the only suspension point, so the activities are in flight together and the worker fans them out across the pool. Same activity, same input, same result array (&lt;code&gt;Task.WhenAll&lt;/code&gt; preserves the order of the &lt;code&gt;tasks&lt;/code&gt; array, so you can return it directly), and at batch scale the difference is roughly 100 activities per second instead of 5.&lt;/p&gt;

&lt;p&gt;The review heuristic: if you see &lt;code&gt;await&lt;/code&gt; on a durable call inside a &lt;code&gt;foreach&lt;/code&gt; or &lt;code&gt;for&lt;/code&gt; loop, stop and ask whether those iterations actually depend on each other. If iteration N needs iteration N-1's output, awaiting in the loop is correct, that is a chain. If they are independent, the &lt;code&gt;await&lt;/code&gt; belongs on a &lt;code&gt;Task.WhenAll&lt;/code&gt; after the loop, and leaving it inside is the silent serialization trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The async HTTP pattern
&lt;/h2&gt;

&lt;p&gt;A 500-item batch that fans out to activities can run for a minute and a half, and no HTTP client should be holding a socket open that long. Browsers, load balancers, and API gateways all time out well before that, so a client function that started the orchestration and blocked on its result would fail the caller before the batch even finished. The async HTTP pattern solves this by separating starting the work from collecting its result: the HTTP trigger kicks off the orchestration and returns immediately with a set of URLs the caller can poll.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgin2j1cisyjdv64egr6p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgin2j1cisyjdv64egr6p.png" alt="Async HTTP pattern: the client POSTs to start the batch and gets back a 202 with a statusQueryGetUri and a Retry-After header, the orchestrator fans out in the background, and the client polls the status endpoint (202 while Running) until it returns 200 with the BatchSummary output." width="712" height="578"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is the client function that starts &lt;code&gt;ProcessBatchOrchestrator&lt;/code&gt; and hands the caller back a status endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Azure.Functions.Worker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Azure.Functions.Worker.Http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.DurableTask.Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StartBatchClient&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StartBatchClient&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HttpResponseData&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthorizationLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Route&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"batches"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
            &lt;span class="n"&gt;HttpRequestData&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DurableClient&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;DurableTaskClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ScheduleNewOrchestrationInstanceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessBatchOrchestrator&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 202 Accepted + Location + the management URLs, without blocking on the result.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateCheckStatusResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ScheduleNewOrchestrationInstanceAsync&lt;/code&gt; enqueues the orchestration and returns its instance id without waiting for it to run. &lt;code&gt;CreateCheckStatusResponseAsync&lt;/code&gt; then builds the response: &lt;strong&gt;HTTP 202 Accepted&lt;/strong&gt;, a &lt;code&gt;Location&lt;/code&gt; header pointing at the status-query endpoint, and a &lt;code&gt;Retry-After&lt;/code&gt; header (10 seconds by default) telling the caller how long to wait before polling again. Prefer the awaited &lt;code&gt;CreateCheckStatusResponseAsync&lt;/code&gt; over the synchronous &lt;code&gt;CreateCheckStatusResponse&lt;/code&gt;: under the ASP.NET Core integration the synchronous form can throw &lt;code&gt;InvalidOperationException&lt;/code&gt; ("Synchronous operations are disallowed"), which is why the async overload exists.&lt;/p&gt;

&lt;p&gt;The JSON body carries the management URLs for the instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7f3c1e9a4b8d4f0e9c2a6b5d8e1f0a3c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusQueryGetUri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../runtime/webhooks/durabletask/instances/7f3c...?..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sendEventPostUri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../instances/7f3c.../raiseEvent/{eventName}?..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"terminatePostUri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../instances/7f3c.../terminate?reason={text}&amp;amp;..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"suspendPostUri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../instances/7f3c.../suspend?reason={text}&amp;amp;..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resumePostUri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../instances/7f3c.../resume?reason={text}&amp;amp;..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"purgeHistoryDeleteUri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../instances/7f3c...?..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;statusQueryGetUri&lt;/code&gt; is the one the caller polls (it is the same URL as the &lt;code&gt;Location&lt;/code&gt; header); the others handle raising an external event, terminating, suspending, resuming, and purging the instance's history. A preview &lt;code&gt;rewindPostUri&lt;/code&gt; shows up too on supported plans.&lt;/p&gt;

&lt;p&gt;From the caller's side it is a poll loop against &lt;code&gt;statusQueryGetUri&lt;/code&gt;. While the instance is still running the status endpoint returns &lt;strong&gt;202&lt;/strong&gt;, with its own &lt;code&gt;Location&lt;/code&gt; header pointing back at itself; once the instance reaches a terminal state it returns &lt;strong&gt;200&lt;/strong&gt; with the full status body, and the body's &lt;code&gt;output&lt;/code&gt; field carries the orchestration's return value. The &lt;code&gt;runtimeStatus&lt;/code&gt; field tells you which state you landed in: &lt;code&gt;Running&lt;/code&gt;, &lt;code&gt;Pending&lt;/code&gt;, &lt;code&gt;Failed&lt;/code&gt;, &lt;code&gt;Canceled&lt;/code&gt;, &lt;code&gt;Terminated&lt;/code&gt;, &lt;code&gt;Completed&lt;/code&gt;, or &lt;code&gt;Suspended&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A curl-style poll honoring &lt;code&gt;Retry-After&lt;/code&gt; is the whole client protocol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;HttpResponseMessage&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"https://.../api/batches"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 202 came back; Location is the status-query URL.&lt;/span&gt;
&lt;span class="n"&gt;Uri&lt;/span&gt; &lt;span class="n"&gt;statusUri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;!;&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;HttpResponseMessage&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusUri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;BatchSummary&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StatusResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;// runtimeStatus == "Completed"; the result is in the body's output field.&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 202: still running. Wait the Retry-After the server asked for.&lt;/span&gt;
    &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetryAfter&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Delta&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your caller can tolerate a short synchronous wait (a small batch that usually finishes in a second or two), &lt;code&gt;WaitForCompletionOrCreateCheckStatusResponseAsync&lt;/code&gt; collapses the round trip: it waits for the instance to complete and returns its output with a 200, and if the wait elapses first it falls back to the same 202 + management-URL payload. The isolated-worker signature is worth a careful read, because it differs from the in-process one: it takes a &lt;code&gt;retryInterval&lt;/code&gt; for the internal poll cadence and a &lt;code&gt;CancellationToken&lt;/code&gt; that bounds the overall wait, but there is &lt;strong&gt;no &lt;code&gt;timeout&lt;/code&gt; parameter&lt;/strong&gt;. You cap the wait by cancelling the token, not by passing a &lt;code&gt;TimeSpan&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory and concurrency limits
&lt;/h2&gt;

&lt;p&gt;Two things bite at batch scale that never show up on a three-item demo: where all those activity results go, and how parallel the fan-out actually runs.&lt;/p&gt;

&lt;p&gt;Start with memory. Every activity output is serialized into the orchestration's history in the &lt;code&gt;&amp;lt;TaskHubName&amp;gt;History&lt;/code&gt; table, and Part 1's replay engine loads that full history into memory each time the orchestrator replays. With a 500-wide fan-out, the fan-in array is 500 serialized results sitting in history and getting rehydrated on every replay. If each result is small that is fine; if each activity returns a multi-megabyte payload, history balloons. Past 45 KB serialized, a result spills over to a &lt;code&gt;&amp;lt;taskhub&amp;gt;-largemessages&lt;/code&gt; blob container automatically (the underlying Azure Queue message hard cap is 64 KB, and the 45 KB threshold leaves headroom for the compressed form). That spillover is correctness-preserving, but it is not free: it costs CPU and IO for the compress-and-round-trip, and the rehydrated payloads still bloat replay memory.&lt;/p&gt;

&lt;p&gt;The fix is to return a reference, not the bytes. Have the activity write the heavy payload to blob storage and return a small id the fan-in can carry cheaply.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BROKEN: the multi-MB image is serialized into history on fan-in.&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RenderPageActivity&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;RenderedPage&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;ActivityTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// multi-MB payload&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;RenderedPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// every byte lands in history&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// FIXED: write the payload to blob storage, return a small reference.&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RenderPageActivity&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PageRef&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;ActivityTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;blobName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Blobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UploadAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"pages/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PageRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blobName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// history carries a reference, not the bytes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whatever needs the bytes later reads them back from blob storage by name. The history stays small, replay stays fast, and you never go near the spillover threshold.&lt;/p&gt;

&lt;p&gt;Now concurrency. Fan-out is not unbounded parallelism. Scheduling 500 activities at once does not mean 500 run at once; the host caps how many activities execute concurrently per instance through &lt;code&gt;maxConcurrentActivityFunctions&lt;/code&gt;, which defaults to &lt;strong&gt;10&lt;/strong&gt; on the Consumption plan (10x the processor count on Dedicated and Premium). Orchestrators have their own ceiling, &lt;code&gt;maxConcurrentOrchestratorFunctions&lt;/code&gt;, defaulting to &lt;strong&gt;5&lt;/strong&gt;. Both live under &lt;code&gt;extensions.durableTask&lt;/code&gt; in host.json:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"durableTask"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"maxConcurrentActivityFunctions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"maxConcurrentOrchestratorFunctions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both limits are per-instance, so scale-out multiplies them: ten workers at the default give you up to 100 activities in flight. The surplus past the concurrency limit does not fail, it queues, and the runtime drains it as slots free up. So a 500-wide fan-out on a single instance runs about 10 at a time with the rest waiting their turn, and the batch is only as parallel as your concurrency setting times your worker count allows. Size the fan-out against that ceiling rather than assuming the width you scheduled is the width that runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The whole pattern turns on where one &lt;code&gt;await&lt;/code&gt; sits. Move it off the individual &lt;code&gt;CallActivityAsync&lt;/code&gt; calls and onto a single &lt;code&gt;Task.WhenAll&lt;/code&gt;, and the same orchestrator that ran 500 items in series now runs them in parallel, replay-safe, across every worker the platform gives you. The async HTTP pattern lets a caller start that batch and walk away with a status URL instead of a held-open socket, and the memory and concurrency caps are the guardrails that keep a wide fan-out from quietly bloating history or pretending to be more parallel than it is.&lt;/p&gt;

&lt;p&gt;Part 3 picks up the other half of orchestration: workflows that pause and wait on something outside the function, like a human approval or an external event, without burning compute while they wait.&lt;/p&gt;

&lt;p&gt;Do you cap your fan-out width with a host.json concurrency limit, or let the platform scale it and size the batch to fit?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>dotnet</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Intro to Durable Functions: Orchestrations and the Chaining Pattern</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 19 Jun 2026 06:01:50 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/intro-to-durable-functions-orchestrations-and-the-chaining-pattern-3mc7</link>
      <guid>https://dev.to/martin_oehlert/intro-to-durable-functions-orchestrations-and-the-chaining-pattern-3mc7</guid>
      <description>&lt;p&gt;An order comes in, and you need to validate it, create it, then send a confirmation, three steps that have to run in order and survive a crash halfway through. A single Azure Function can't do that, because the moment it returns it forgets everything, so the usual fix is a chain of queue-triggered functions wired together with a correlation ID, a status table, and your own retry logic. That hand-rolled state machine is exactly what Durable Functions replaces, and the price of admission is learning to write an orchestrator: normal-looking C# that the runtime is allowed to run more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a stateless function can't run a workflow
&lt;/h2&gt;

&lt;p&gt;A plain Azure Function is a single invocation. It receives a trigger, runs, returns, and the worker that ran it can be recycled the instant it finishes. Nothing in the function body survives to the next invocation: no local variables, no "where was I" pointer, no record that step two of a three-step process already succeeded. That design is what makes Functions cheap to scale, and it's the right model for the bulk of event handlers you write.&lt;/p&gt;

&lt;p&gt;It stops being enough the moment one logical unit of work spans more than one step. Take the order example: validate the request, create the order record, send a confirmation. You want those to run in sequence, you want the second step to use the output of the first, and you want the whole thing to pick up where it left off if the host restarts between steps. None of that is possible inside one function, so the standard pattern is to split each step into its own queue-triggered function and pass a message down the chain.&lt;/p&gt;

&lt;p&gt;That works, but look at what you end up owning. You need a &lt;strong&gt;correlation ID&lt;/strong&gt; so you can tell which messages belong to the same order. You need a status table so you can answer "is order 4815 done yet" and so a retry doesn't redo a step that already completed. You need poison-queue handling, timeout logic, and some way to fan results back together if any step branches. You have hand-built a state machine, and state machines spread across five queues are where the 2 a.m. pages come from.&lt;/p&gt;

&lt;p&gt;Durable Functions takes over the state. It records every step your workflow completes in durable storage, and it reconstructs your workflow's position from that record after any interruption. You write the sequence as ordinary C# with &lt;code&gt;await&lt;/code&gt; between the steps; the runtime makes the sequence survive crashes, scale-ins, and host upgrades. The correlation ID, the status table, and the retry bookkeeping all move from your code into the framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three roles: orchestrator, activity, client
&lt;/h2&gt;

&lt;p&gt;Durable Functions splits a workflow into three kinds of function, each identified by a trigger or binding type. Keeping them straight is most of the battle when you're starting out, because the rules about what code is legal where depend entirely on which role you're in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fliegj8nkj5b21jyi1v7s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fliegj8nkj5b21jyi1v7s.png" alt="The three Durable Functions roles: an HTTP-triggered client starts the orchestrator, the orchestrator coordinates the activities, and the task hub in Azure Storage holds the durable state." width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;orchestrator&lt;/strong&gt; is the workflow itself. It's a function marked with the &lt;code&gt;[OrchestrationTrigger]&lt;/code&gt; binding, and its job is to coordinate: call this step, wait for the result, decide what to call next. It contains the control flow (&lt;code&gt;if&lt;/code&gt;, loops, sequencing) but does no real work of its own. The orchestrator is the one role with a hard constraint attached. Its body can be re-executed many times over the life of a single workflow instance, so the code in it must be &lt;strong&gt;deterministic&lt;/strong&gt;. That single fact (the orchestrator replays) is what the rest of this series keeps coming back to; the replay mechanics and the exact list of rules are covered later in this article.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;activity&lt;/strong&gt; is where the actual work happens. It's a function marked with &lt;code&gt;[ActivityTrigger]&lt;/code&gt;, and it's the only one of the three roles allowed to touch the outside world: database writes, HTTP calls, sending email, reading a blob. Activities can bind directly to their input type, so an activity that validates an order can take an &lt;code&gt;OrderRequest&lt;/code&gt; parameter and nothing else. The guarantee Durable Functions gives you on activities is &lt;strong&gt;at-least-once&lt;/strong&gt; execution, and that has a real consequence. An activity can run more than once for the same logical step (after a transient failure and retry, for instance), so activity logic should be &lt;strong&gt;idempotent&lt;/strong&gt;. Sending a confirmation email twice because the worker died after sending but before recording success is the kind of bug this guarantee invites if you're not careful.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;client&lt;/strong&gt; (often called the starter) is the entry point that kicks a workflow off and lets you query it. In the isolated worker model the client is the injected &lt;code&gt;DurableTaskClient&lt;/code&gt;, supplied through the &lt;code&gt;[DurableClient]&lt;/code&gt; binding on a normal trigger such as an HTTP function. It is not something you call from inside an orchestrator; it lives in regular functions that start instances with &lt;code&gt;ScheduleNewOrchestrationInstanceAsync&lt;/code&gt; and hand back a status response the caller can poll.&lt;/p&gt;

&lt;p&gt;Behind all three sits the &lt;strong&gt;task hub&lt;/strong&gt;: the set of Azure Storage resources (queues, tables, and a couple of blob containers) that the default storage provider creates in your function app's storage account to hold the workflow's messages and history. You don't provision it or write to it directly. It's enough to know it exists and that it's where your workflow's durable state actually lives; the history table inside it is the star of the replay section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chaining pattern
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Function chaining&lt;/strong&gt; is the simplest orchestration and the one you'll reach for most. You run a sequence of activities in order, where each step's output feeds the next. Here is the order-processing workflow as an orchestrator.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg82p40xj3tdh5kxxsvp2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg82p40xj3tdh5kxxsvp2.png" alt="Function chaining: the client starts the orchestrator, which calls Validate, then Create, then SendConfirmation in sequence, with each step's output feeding the next." width="799" height="446"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Azure.Functions.Worker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.DurableTask&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderOrchestrator&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderOrchestrator&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RunOrchestrator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OrchestrationTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()!;&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"Order validation failed"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SendConfirmationActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it top to bottom and it's the sequence from the opening, written as plain C#. &lt;code&gt;context.GetInput&amp;lt;OrderRequest&amp;gt;()&lt;/code&gt; pulls the input the client passed when it started the instance; the &lt;code&gt;!&lt;/code&gt; is there because the input is typed as nullable and you know this orchestrator always gets one. Each &lt;code&gt;await context.CallActivityAsync(...)&lt;/code&gt; schedules an activity and waits for its result before moving on, which is what gives you the chain: &lt;code&gt;validated&lt;/code&gt; gates whether the order is created, and &lt;code&gt;orderId&lt;/code&gt; (the output of &lt;code&gt;CreateOrderActivity&lt;/code&gt;) becomes the input to &lt;code&gt;SendConfirmationActivity&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The call comes in two shapes. When an activity returns a value you use the generic &lt;code&gt;CallActivityAsync&amp;lt;TResult&amp;gt;&lt;/code&gt;, which gives you back a &lt;code&gt;Task&amp;lt;TResult&amp;gt;&lt;/code&gt;: &lt;code&gt;CallActivityAsync&amp;lt;bool&amp;gt;&lt;/code&gt; for the validation result, &lt;code&gt;CallActivityAsync&amp;lt;string&amp;gt;&lt;/code&gt; for the new order ID. When an activity is fire-the-step with nothing to return, you use the non-generic &lt;code&gt;CallActivityAsync&lt;/code&gt;, which returns a plain &lt;code&gt;Task&lt;/code&gt;; that's the confirmation send. The first argument is the activity name as a &lt;code&gt;TaskName&lt;/code&gt;, and since there's an implicit conversion from &lt;code&gt;string&lt;/code&gt;, &lt;code&gt;nameof(ValidateOrderActivity)&lt;/code&gt; works directly and keeps the name refactor-safe.&lt;/p&gt;

&lt;p&gt;The activity is an ordinary function that does the real work. This is where I/O is allowed, so it's where validation against your database or rules engine actually happens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ValidateOrderActivity&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;ActivityTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;OrderRequest&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Real work and I/O belong here, never in the orchestrator:&lt;/span&gt;
        &lt;span class="c1"&gt;// check inventory, validate the customer, hit the database.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The activity binds straight to &lt;code&gt;OrderRequest&lt;/code&gt;, the same type the orchestrator passed as input, so there's no manual deserialization. Keep in mind the at-least-once guarantee from earlier. If &lt;code&gt;ValidateOrderActivity&lt;/code&gt; did something with side effects, you'd want running it twice to be safe. Pure validation like this is naturally idempotent, which is one reason it's a good first step in the chain.&lt;/p&gt;

&lt;p&gt;Something has to start the workflow. That's the client, here an HTTP-triggered function that schedules a new instance and returns a status response the caller can poll.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Azure.Functions.Worker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Azure.Functions.Worker.Http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.DurableTask.Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderClient&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StartOrder&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HttpResponseData&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;StartOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthorizationLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"post"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;HttpRequestData&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DurableClient&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;DurableTaskClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ScheduleNewOrchestrationInstanceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderOrchestrator&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateCheckStatusResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ScheduleNewOrchestrationInstanceAsync&lt;/code&gt; starts the orchestrator with the deserialized order as input and returns the new instance's ID. &lt;code&gt;CreateCheckStatusResponse&lt;/code&gt; then builds an &lt;code&gt;HttpResponseData&lt;/code&gt; that's an HTTP 202 (Accepted) carrying a set of management URLs (status, terminate, and so on) keyed to that instance ID. The workflow runs asynchronously; the HTTP caller gets an immediate 202 and uses the status URL to find out when the order is done. The orchestrator never starts itself, and the client never contains workflow logic; each role stays in its lane.&lt;/p&gt;

&lt;p&gt;One pin before you copy this into a project: the Durable extension is &lt;code&gt;Microsoft.Azure.Functions.Worker.Extensions.DurableTask&lt;/code&gt; version &lt;strong&gt;1.16.5&lt;/strong&gt; (the 1.x line, even on .NET 10), and &lt;code&gt;TaskOrchestrationContext&lt;/code&gt; lives in &lt;code&gt;Microsoft.DurableTask&lt;/code&gt; while &lt;code&gt;DurableTaskClient&lt;/code&gt; lives in &lt;code&gt;Microsoft.DurableTask.Client&lt;/code&gt;. These are the isolated-worker types; the in-process model used different names (&lt;code&gt;IDurableOrchestrationContext&lt;/code&gt;, a different client), and mixing the two is the most common reason a copied snippet won't compile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replay mechanics
&lt;/h2&gt;

&lt;p&gt;The orchestrator code reads like it runs once, top to bottom. It doesn't. To understand every rule that follows, you have to start from the one mechanic that drives them all: the orchestrator function body runs many times over the life of a single workflow instance.&lt;/p&gt;

&lt;p&gt;Durable Functions doesn't snapshot the orchestrator's current state and resume it. It uses &lt;strong&gt;event sourcing&lt;/strong&gt;. Every action the orchestrator takes (an activity scheduled, an activity completed, a timer created, a result returned) is appended to an &lt;strong&gt;append-only log&lt;/strong&gt; in the History table, which lives in the task hub from the previous section. That log, not the in-memory state of the function, is the source of truth for where the workflow is.&lt;/p&gt;

&lt;p&gt;Here is what actually happens when the order orchestrator runs. The first time, it executes from the top, hits &lt;code&gt;await context.CallActivityAsync&amp;lt;bool&amp;gt;(nameof(ValidateOrderActivity), order)&lt;/code&gt;, and yields. The dispatcher commits "ValidateOrderActivity scheduled" to the History table and unloads the orchestrator from memory entirely. There is now no thread, no stack, nothing in RAM waiting; the workflow exists only as rows in storage. When the validation activity finishes, its result is written to history and the orchestrator is woken back up.&lt;/p&gt;

&lt;p&gt;On that wake-up, the orchestrator runs again from the very first line. It reaches the same &lt;code&gt;CallActivityAsync&lt;/code&gt; call, but this time the framework checks the History table, sees that &lt;code&gt;ValidateOrderActivity&lt;/code&gt; already completed, and &lt;strong&gt;replays the result from history instead of re-running the activity&lt;/strong&gt;. The activity does not execute a second time; the recorded &lt;code&gt;true&lt;/code&gt; (or &lt;code&gt;false&lt;/code&gt;) is read straight out of storage and handed back, the &lt;code&gt;validated&lt;/code&gt; local gets the value it had on the first run, and execution fast-forwards to the first step that hasn't completed yet, &lt;code&gt;CreateOrderActivity&lt;/code&gt;. That step is now scheduled, the orchestrator yields again, and the cycle repeats until &lt;code&gt;SendConfirmationActivity&lt;/code&gt; returns and the orchestrator runs to completion.&lt;/p&gt;

&lt;p&gt;This is precisely what lets a workflow survive a crash. If the host dies after creating the order but before sending the confirmation, the History table still holds "order created" with its result. When a new worker picks the instance up, it replays from the top, fast-forwards past validation and creation using the recorded results, and resumes at exactly the confirmation step. No step that already succeeded runs twice as part of recovery, because recovery is just replay against the same history.&lt;/p&gt;

&lt;p&gt;One practical consequence shows up the first time you add a log line to an orchestrator. It fires on every replay, so a single workflow can emit the same log message several times. The context exposes &lt;code&gt;context.IsReplaying&lt;/code&gt; so you can suppress noise from replayed execution.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderOrchestrator&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RunOrchestrator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OrchestrationTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ILogger&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()!;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsReplaying&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Starting order for customer {CustomerId}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... rest of the chain&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;if (!context.IsReplaying)&lt;/code&gt; guard means the "Starting order" line is written once, on the genuine first pass, and skipped on every replay. Without it the line would appear once for every time the orchestrator is dispatched, roughly once per activity in the chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Determinism rules
&lt;/h2&gt;

&lt;p&gt;Replay is also why the orchestrator is the one role with code restrictions. If the body re-executes from the top every time, then any line that produces a different value on the second run than it did on the first will make the workflow take a different path during replay than it took originally, and the state reconstructed from history no longer matches the code's decisions. So the rule is blunt: &lt;strong&gt;orchestrator code must be deterministic&lt;/strong&gt;. The same inputs and the same history must always produce the same sequence of calls.&lt;/p&gt;

&lt;p&gt;These restrictions apply only to orchestrators. Activities can do anything; that's the point of them. It's the orchestrator, and only the orchestrator, that has to behave identically on every pass.&lt;/p&gt;

&lt;p&gt;Here is the trap, written the way it usually gets written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BROKEN inside an orchestrator: re-evaluates on every replay.&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderOrchestrator&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RunOrchestrator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OrchestrationTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()!;&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;receivedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// different value every replay&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;           &lt;span class="c1"&gt;// a new GUID every replay&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both &lt;code&gt;DateTime.UtcNow&lt;/code&gt; and &lt;code&gt;Guid.NewGuid()&lt;/code&gt; look harmless. The problem is that the first run records one timestamp and one GUID, then every replay computes fresh ones. If &lt;code&gt;receivedAt&lt;/code&gt; or &lt;code&gt;traceId&lt;/code&gt; ever feeds a branch, a comparison, or an activity input, the replayed run disagrees with the recorded history and the workflow corrupts. The failure is intermittent (replay only happens after a yield, and only some values flow into decisions), which is exactly what makes it hard to catch in testing and ugly in production.&lt;/p&gt;

&lt;p&gt;The fix is to take time and identity from the context, which returns replay-stable values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// CORRECT: context helpers return the same value on every replay.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;receivedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CurrentUtcDateTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// same instant every replay&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;               &lt;span class="c1"&gt;// same GUID every replay&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;context.CurrentUtcDateTime&lt;/code&gt; records the current UTC time on the first execution and replays that exact instant afterward. &lt;code&gt;context.NewGuid()&lt;/code&gt; produces a replay-safe GUID the same way. (One naming gotcha: the property is &lt;code&gt;CurrentUtcDateTime&lt;/code&gt;. Some docs prose mis-spells it &lt;code&gt;CurrentDateTimeUtc&lt;/code&gt;, which does not exist and will not compile.)&lt;/p&gt;

&lt;p&gt;The same reasoning rules out a few more things. Don't call &lt;code&gt;new Random()&lt;/code&gt; in an orchestrator; if you need randomness, return it from an activity, where the result is saved to history and replayed like any other activity output. Don't read environment variables or configuration directly, since those can change between the first run and a replay hours later; pass config in as orchestrator input or fetch it from an activity. And don't do real I/O (database, file, HTTP) in the orchestrator: it would fire again on every replay, and a network call is never replay-stable anyway. Push all of it into activities.&lt;/p&gt;

&lt;p&gt;Delays have their own replay-safe form. A &lt;code&gt;Task.Delay&lt;/code&gt; or &lt;code&gt;Thread.Sleep&lt;/code&gt; in an orchestrator both blocks a thread and re-evaluates on replay; the durable equivalent is &lt;code&gt;context.CreateTimer&lt;/code&gt;, which records the wake-up time in history and releases the worker entirely until then, so a workflow can wait minutes or days without holding any resources.&lt;/p&gt;

&lt;p&gt;People trip on this rule for a fair reason: the broken code compiles, passes a quick local test, and looks like ordinary C#. The orchestrator only betrays you under replay, and replay only happens after a yield on a worker that may not be the one that started the run. The mental shortcut that keeps you safe is to read every line of an orchestrator and ask whether it would return the same value if the method ran again right now. If the answer is no, it belongs in an activity or behind a context helper.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use Durable Functions vs queues
&lt;/h2&gt;

&lt;p&gt;Durable Functions is not the default answer to "my functions need to talk to each other." A plain storage queue with a table for state is cheaper, simpler to provision, and entirely enough for a large class of problems. If the work is a single hand-off (one function drops a message, another picks it up, does its job, and that's the end of it) a queue is the right tool. Keep those functions stateless and idempotent, carry whatever state you need on the message itself, and you never have to think about replay rules or determinism.&lt;/p&gt;

&lt;p&gt;The line to watch for is &lt;strong&gt;stateful coordination across steps&lt;/strong&gt;. The moment you need the output of one step to drive the next, retries that don't redo work that already succeeded, results from parallel branches aggregated back together, a workflow that waits on an external event or a human approval, durable delays measured in hours or days, or a status endpoint that answers "where is order 4815 right now," a bare queue stops being enough. You can build all of that on queues and tables, but you'll be hand-rolling correlation IDs, a status table, poison-message handling, and timeout plumbing across several queues. That hand-rolled state machine is exactly the thing an orchestrator replaces, and it replaces it with code that reads like the workflow it implements.&lt;/p&gt;

&lt;p&gt;So the honest recommendation: reach for a queue first. Don't pull in Durable Functions for a single fire-and-forget hand-off; the orchestrator's constraints and the task hub's storage footprint are real overhead that buys you nothing there. The signal to switch is the second or third piece of coordination bookkeeping you find yourself writing by hand. When you're maintaining a correlation ID and a status table and retry logic just to keep a multi-step process straight, you've already built a worse version of what Durable Functions gives you, and that's when the orchestration earns its complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Chaining is the first of several orchestration patterns, and it's deliberately the simplest: a straight line of activities. The same replay engine powers fan-out/fan-in (running activities in parallel and aggregating their results), waiting on external events for human-in-the-loop approval, and durable entities for stateful objects. Each is a later part of this series, and each rests on the single fact this article was built around: the orchestrator is C# that replays.&lt;/p&gt;

&lt;p&gt;Which coordination problem pushed you past a plain queue first: a multi-step sequence that needed to survive restarts, fan-out with result aggregation, or waiting on an external event or approval?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>dotnet</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Deploying .NET Aspire Apps to Azure: AZD, ACA, and What Aspire Generates</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:26:46 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/deploying-net-aspire-apps-to-azure-azd-aca-and-what-aspire-generates-4kk1</link>
      <guid>https://dev.to/martin_oehlert/deploying-net-aspire-apps-to-azure-azd-aca-and-what-aspire-generates-4kk1</guid>
      <description>&lt;p&gt;Parts 1 and 2 made one AppHost the source of truth for every connection your functions trigger on, locally. The question for Part 3 is what happens to that graph when you deploy it: which AppHost resources become real Azure infrastructure, and which trigger connections you still have to wire by hand. There are two toolchains that can do the deploy now, so the first decision is which one. This article uses &lt;code&gt;azd&lt;/code&gt;, generates the Bicep, and reads it line by line to find the boundary where the generator stops and you take over.&lt;/p&gt;

&lt;h2&gt;
  
  
  From local to Azure
&lt;/h2&gt;

&lt;p&gt;The AppHost is already a resource graph. Deployment reads that graph and turns it into Azure infrastructure; it is not a second pipeline you write and keep in sync with the first. That is the whole premise: the same &lt;code&gt;AddAzureServiceBus("messaging")&lt;/code&gt; that started an emulator container in Part 2 provisions a real namespace at publish, and the same &lt;code&gt;WithReference&lt;/code&gt; that resolved a local connection string emits an Azure role assignment and an identity-based environment variable.&lt;/p&gt;

&lt;p&gt;Two toolchains turn that graph into Azure today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;azd&lt;/code&gt;&lt;/strong&gt; (Azure Developer CLI): the mature, CI-friendly path. It detects the AppHost, generates Bicep from the resource graph, provisions, and deploys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;aspire deploy&lt;/code&gt;&lt;/strong&gt; (built on the newer &lt;code&gt;aspire publish&lt;/code&gt; / &lt;code&gt;aspire do&lt;/code&gt; pipeline): what Microsoft now recommends as the default for new Aspire projects. The aspire.dev guidance is explicit that &lt;code&gt;azd&lt;/code&gt; "is still supported with Aspire for existing workflows, but it is no longer the recommended default deployment path."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This article uses &lt;code&gt;azd&lt;/code&gt; because it is the path with stable CI/CD integration, federated identity, and a documented pipeline generator, which is what most teams shipping today are on. The infrastructure both toolchains generate is the same; the commands differ. Three commands form the &lt;code&gt;azd&lt;/code&gt; spine: &lt;code&gt;azd init&lt;/code&gt;, &lt;code&gt;azd provision&lt;/code&gt;, &lt;code&gt;azd deploy&lt;/code&gt;. Knowing what each does, and where the split between them matters, is the rest of the deploy story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AZD workflow
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;azd init&lt;/code&gt; run from the solution directory scans the tree and detects the AppHost project. It keys off the Aspire AppHost SDK markers in the &lt;code&gt;.csproj&lt;/code&gt;, not a folder name, and reports the detected service before writing anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;azd init
&lt;span class="go"&gt;? How do you want to initialize your app? Use code in the current directory

Scanning app code in current directory
  (✓) Done: Scanning app code in current directory

Detected services:

  .NET (Aspire)
  Detected in: ./AspireDemo.AppHost/AspireDemo.AppHost.csproj

azd will generate the files necessary to host your app on Azure.

Generating files to run your app on Azure:

  (✓) Done: Generating ./azure.yaml
  (✓) Done: Generating ./next-steps.md

SUCCESS: Your app is ready for the cloud!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it writes is small. An &lt;code&gt;azure.yaml&lt;/code&gt; at the root maps the AppHost to Azure, plus a &lt;code&gt;.azure/&amp;lt;env&amp;gt;/&lt;/code&gt; folder holding per-environment config and an &lt;code&gt;.env&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aspire-demo&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dotnet&lt;/span&gt;
    &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./AspireDemo.AppHost/AspireDemo.AppHost.csproj&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;containerapp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;host: containerapp&lt;/code&gt; records the target family; it does not by itself decide the infrastructure. &lt;code&gt;azd&lt;/code&gt; reads the compute target from the AppHost, which is why the &lt;code&gt;AddAzureContainerAppEnvironment("aca-env")&lt;/code&gt; line added in Part 3's sample matters (more on that below). With &lt;code&gt;azure.yaml&lt;/code&gt; in place, two commands carry the deploy, and they are deliberately separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;azd provision&lt;/code&gt;&lt;/strong&gt; creates and configures the Azure resources from the generated infrastructure. It pushes no application code. Run it and you get a resource group, a Container Apps environment, a registry, the backing services, identities, and role assignments, all empty of your images. Against a real subscription, the Part 3 sample provisioned its ten resources in 6 minutes 25 seconds, the Container Apps environment alone taking 3 minutes 24 of those.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;azd deploy&lt;/code&gt;&lt;/strong&gt; builds your projects, pushes the images to the registry, and creates new Container Apps revisions wired to the provisioned resources. It creates no infrastructure. Same sample: 7 minutes 32 seconds to build the three Functions images plus the Redis container locally, push them, and bring the four apps up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;azd up&lt;/code&gt; runs both in one pass (&lt;code&gt;package&lt;/code&gt; then &lt;code&gt;provision&lt;/code&gt; then &lt;code&gt;deploy&lt;/code&gt;) and is the right call when you are iterating locally and do not care about the seam. In CI/CD the seam is the point: provision changes infrastructure on a slow, reviewed cadence; deploy ships code many times a day. Splitting them lets infra changes gate behind approval while app deploys stay fast.&lt;/p&gt;

&lt;p&gt;One honest caveat the split carries: when only infrastructure changes, &lt;code&gt;azd provision&lt;/code&gt; updates the resources but does not refresh connection values in the already-running apps. You run &lt;code&gt;azd deploy&lt;/code&gt; again to pick those up. The Azure docs put it plainly: when in doubt, use &lt;code&gt;azd up&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Aspire generates
&lt;/h2&gt;

&lt;p&gt;This is the part worth slowing down on, because the generated infrastructure is the contract between your AppHost and Azure, and reading it tells you exactly what you own. The artifact is Bicep. (Aspire also emits an &lt;code&gt;aspire-manifest.json&lt;/code&gt;, but it is now a deprecated compatibility format kept for &lt;code&gt;azd&lt;/code&gt; interop, not the thing to center your mental model on. The Bicep is the truth.)&lt;/p&gt;

&lt;p&gt;Generated from the Part 3 AppHost, the tree is one file per resource with a &lt;code&gt;main.bicep&lt;/code&gt; that stitches them together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;main.bicep                       targetScope = 'subscription'; creates the RG, calls one module per resource
aca-env/aca-env.bicep            managed identity, ACR + AcrPull, Log Analytics, the managed environment
aca-env-acr/aca-env-acr.bicep    the container registry
host-storage/host-storage.bicep  AzureWebJobsStorage account (shared by all three functions)
app-storage/app-storage.bicep    the receipts storage account
messaging/messaging.bicep        the Service Bus namespace + orders queue
cache/cache.bicep                Redis (published as a container)
orders-http/orders-http.bicep    the HTTP-trigger function app (+ identity, + roles modules)
orders-queue/orders-queue.bicep  the queue-trigger function app (+ identity, + roles modules)
orders-sb/orders-sb.bicep        the Service Bus function app (+ identity, + three roles modules)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;main.bicep&lt;/code&gt; targets the subscription scope, creates the resource group, and calls every module with one &lt;code&gt;module&lt;/code&gt; block apiece. The shape is mechanical, by design: every AppHost resource has a one-to-one Bicep module, and the wiring between them is explicit parameters and outputs, not magic.&lt;/p&gt;

&lt;p&gt;The environment module is where Aspire sets up the platform every app runs on. &lt;code&gt;aca-env.bicep&lt;/code&gt; provisions a user-assigned managed identity, an Azure Container Registry with an &lt;code&gt;AcrPull&lt;/code&gt; role assignment for that identity, a Log Analytics workspace, the Container Apps managed environment itself (Consumption profile), and the Aspire dashboard component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resource aca_env 'Microsoft.App/managedEnvironments@2025-07-01' = {
  name: take('acaenv${uniqueString(resourceGroup().id)}', 24)
  location: location
  properties: {
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: aca_env_law.properties.customerId
        sharedKey: aca_env_law.listKeys().primarySharedKey
      }
    }
    workloadProfiles: [
      { name: 'consumption', workloadProfileType: 'Consumption' }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each function project becomes a &lt;code&gt;Microsoft.App/containerApps&lt;/code&gt; resource, and the single most important detail in the tree is the last line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resource orders_sb 'Microsoft.App/containerApps@2025-10-02-preview' = {
  name: 'orders-sb'
  // ...
  identity: {
    type: 'UserAssigned'
    userAssignedIdentities: {
      '${orders_sb_identity_outputs_id}': { }
      '${aca_env_outputs_azure_container_registry_managed_identity_id}': { }
    }
  }
  kind: 'functionapp'
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kind: 'functionapp'&lt;/code&gt; is not cosmetic. It is the Functions-optimized Container Apps variant, and it is what lets the platform derive KEDA autoscaler rules from your trigger attributes instead of you writing scale rules. A plain container app does not get that. Aspire emits &lt;code&gt;kind: 'functionapp'&lt;/code&gt; on all three apps because all three are &lt;code&gt;AddAzureFunctionsProject&lt;/code&gt;, not &lt;code&gt;AddProject&lt;/code&gt;. The deployed apps confirm it: the three Functions apps report &lt;code&gt;functionapp&lt;/code&gt;, the Redis container app reports nothing. One trap if you go check this yourself: ARM only returns &lt;code&gt;kind&lt;/code&gt; from api-version &lt;code&gt;2025-07-01&lt;/code&gt; onward. Query a container app with &lt;code&gt;2024-03-01&lt;/code&gt; and the field is silently absent, which looks exactly like Aspire forgot to set it.&lt;/p&gt;

&lt;p&gt;The connections from Part 2 land as identity-based environment variables, not connection strings. The Service Bus app's container carries exactly what its three &lt;code&gt;WithReference&lt;/code&gt; calls declared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;env: [
  { name: 'messaging__fullyQualifiedNamespace', value: messaging_outputs_servicebusendpoint }
  { name: 'receipts__blobServiceUri',           value: app_storage_outputs_blobendpoint }
  { name: 'AzureWebJobsStorage__blobServiceUri', value: host_storage_outputs_blobendpoint }
  // ...table, queue, dataLake service URIs for host storage...
  { name: 'AZURE_CLIENT_ID',          value: orders_sb_identity_outputs_clientid }
  { name: 'AZURE_TOKEN_CREDENTIALS',  value: 'ManagedIdentityCredential' }
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No secrets for Service Bus or Storage; the app authenticates as its user-assigned identity, and &lt;code&gt;AZURE_TOKEN_CREDENTIALS=ManagedIdentityCredential&lt;/code&gt; pins the credential type so the SDK does not waste startup probing the others. Redis is the exception in this sample: because it is a containerized Redis rather than a managed Azure resource, its connection comes in as an ACA secret (&lt;code&gt;connectionstrings--cache&lt;/code&gt;), not an identity.&lt;/p&gt;

&lt;p&gt;For every referenced resource, Aspire generates a role assignment module. The Service Bus app gets three: &lt;code&gt;AzureServiceBusDataOwner&lt;/code&gt; on the namespace, and Blob plus the rest of the Data Contributor set on the storage accounts it touches. Read the host-storage role module closely; it tells you the default access level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// host-storage roles for orders-sb: three Data Contributor assignments, nothing more
resource host_storage_StorageBlobDataContributor  // ba92f5b4-...
resource host_storage_StorageTableDataContributor // 0a9a7e1f-...
resource host_storage_StorageQueueDataContributor // 974c5e8b-...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three Data Contributor roles. No Storage Account Contributor (that is what &lt;code&gt;.WithHostStorage(...)&lt;/code&gt; drops), and no Blob Data &lt;strong&gt;Owner&lt;/strong&gt;. For most function workloads Data Contributor is enough; if your function needs to manage access policies or container ACLs, the default is too weak and you will widen it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the generated happy path stops: wiring Functions triggers
&lt;/h2&gt;

&lt;p&gt;Everything above wired itself because this sample stays inside the supported set. That set is exactly four integrations: &lt;strong&gt;Azure Blob Storage, Azure Queue Storage, Azure Event Hubs, and Azure Service Bus&lt;/strong&gt;. Hand any of those four to a Functions project with &lt;code&gt;WithReference&lt;/code&gt;, and Aspire wires the connection, the identity, the role assignment, and the KEDA scaler. The Functions integration has been GA since 13.1, so this is dependable behavior rather than a moving target.&lt;/p&gt;

&lt;p&gt;The boundary has sharp edges even on the happy path, and four of them show up in the Bicep you just read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The connection name is a string you have to match by hand.&lt;/strong&gt; On the AppHost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_ServiceBus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-sb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHostStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostStorage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messaging&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;receipts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"receipts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the trigger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ConfirmOrder&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;BlobOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"receipts/{OrderId}.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"receipts"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ConfirmOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ServiceBusTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;OrderMessage&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"messaging"&lt;/code&gt; and &lt;code&gt;"receipts"&lt;/code&gt; appear in both places, and they have to agree. The environment variable Aspire emits is &lt;code&gt;messaging__fullyQualifiedNamespace&lt;/code&gt;; the trigger's &lt;code&gt;Connection = "messaging"&lt;/code&gt; is what reads it. Misspell one side and nothing throws at deploy time. The function just never fires, because the runtime looks for a connection named after the trigger and finds none.&lt;/p&gt;

&lt;p&gt;Matched correctly, the chain holds up live, not just on paper. A message posted to the deployed &lt;code&gt;orders&lt;/code&gt; queue fired &lt;code&gt;ConfirmOrder&lt;/code&gt; under the app's user-assigned identity and wrote the receipt blob, with no connection string anywhere in its environment, running on exactly the &lt;code&gt;AZURE_TOKEN_CREDENTIALS=ManagedIdentityCredential&lt;/code&gt; setup from the generated Bicep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-06-12T08:40:09.84 info: Function.ConfirmOrder.User[0]  Confirm order w24-live-001
2026-06-12T08:40:10.92 warn: Azure.Core  404 The specified container does not exist (ContainerNotFound)
2026-06-12T08:40:11.35 info: Function.ConfirmOrder[2]  Executed 'Functions.ConfirmOrder' (Succeeded, Id=71d7af99-4755-44a9-9f22-d81017f1f180, Duration=2211ms)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that log are worth knowing before you reproduce it. The 404 in the middle is not the connection failing: &lt;code&gt;[BlobOutput]&lt;/code&gt; logs the miss and then creates the &lt;code&gt;receipts&lt;/code&gt; container itself on first write. And the test setup needs data-plane roles you might not expect. The generated namespace and storage accounts disable key-based access (&lt;code&gt;disableLocalAuth&lt;/code&gt;, &lt;code&gt;allowSharedKeyAccess: false&lt;/code&gt;), so even an Owner on the subscription cannot post a test message or list the output blobs; sending the message took a temporary Azure Service Bus Data Sender assignment, and reading the receipt a Storage Blob Data Reader one. Plan both into however you smoke-test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External HTTP ingress is off by default.&lt;/strong&gt; Every app in the generated Bicep, including the HTTP-trigger one, has &lt;code&gt;ingress: { external: false }&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ingress: {
  external: false
  targetPort: 8080
  transport: 'http'
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTP function is reachable inside the Container Apps environment, not from the internet, because the AppHost never called &lt;code&gt;.WithExternalHttpEndpoints()&lt;/code&gt;. If you expected to curl your HTTP trigger after deploy, this is why you cannot. The live deploy spells it out in the hostnames: all four apps come back with &lt;code&gt;*.internal.*&lt;/code&gt; FQDNs, such as &lt;code&gt;orders-http.internal.nicesand-421ce770.westeurope.azurecontainerapps.io&lt;/code&gt;, and the only external URL in the whole deployment belongs to the Aspire dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No scale-to-zero by default.&lt;/strong&gt; Every app has &lt;code&gt;scale: { minReplicas: 1 }&lt;/code&gt;. The function apps do not idle down to zero; you pay for at least one replica each, always. KEDA still scales them up from triggers, but the floor stays at one. There is no &lt;code&gt;rules:&lt;/code&gt; block in the Bicep either; the platform derives the scaler from &lt;code&gt;kind: 'functionapp'&lt;/code&gt;. The deployed apps confirm the floor: all four run with &lt;code&gt;minReplicas: 1&lt;/code&gt; from the moment deploy finishes.&lt;/p&gt;

&lt;p&gt;Step outside the four supported integrations and the wiring stops entirely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anything else needs &lt;code&gt;WithEnvironment&lt;/code&gt; plus an &lt;code&gt;IsPublishMode&lt;/code&gt; branch.&lt;/strong&gt; &lt;code&gt;WithReference&lt;/code&gt; exposes config to client integrations but not to triggers and bindings outside the four. For those you write the environment variable yourself, usually with a publish-mode branch that appends the &lt;code&gt;__serviceUri&lt;/code&gt; suffix for identity-based connections, because the local and published shapes differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing or connection-string-only resources are not picked up.&lt;/strong&gt; A resource added with &lt;code&gt;AddConnectionString(...)&lt;/code&gt;, or the &lt;code&gt;IsPublishMode ? AddAzureServiceBus(...) : AddConnectionString(...)&lt;/code&gt; pattern the Service Bus docs suggest, is not wired into a Functions trigger. This is &lt;a href="https://github.com/microsoft/aspire/issues/6465" rel="noopener noreferrer"&gt;microsoft/aspire #6465&lt;/a&gt;, closed &lt;code&gt;not_planned&lt;/code&gt;. If you were hoping to point a trigger at a pre-existing namespace, that is the gap to plan around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP-trigger access keys are not managed.&lt;/strong&gt; Aspire does not create or rotate Functions access keys. An HTTP trigger that defaults to requiring a key has no key provisioned; you either set the auth level to anonymous or wire Key Vault secrets yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A plain &lt;code&gt;[BlobTrigger]&lt;/code&gt; does not autoscale on ACA.&lt;/strong&gt; Only the Event Grid-based blob trigger source scales; the polling blob trigger does not. If you need a blob-driven function to scale, switch it to the Event Grid source or accept a fixed replica count.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That list is the real payoff. The four-integration happy path is genuinely good, and this sample rides it end to end. The moment your architecture needs a fifth connection type, an existing resource, a public HTTP endpoint, or scale-to-zero, you are writing the wiring, and knowing that before you deploy saves you a silent failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Owning the infrastructure
&lt;/h2&gt;

&lt;p&gt;The instinct when you hit a gap is to open the generated Bicep and edit it. Resist that. Hand-edits to generated Bicep are overwritten the next time the generator runs, on both toolchains. There are two correct ways to own your infrastructure, and editing-then-regenerating is neither.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customise in C# first.&lt;/strong&gt; Most of what you would reach into Bicep for has an AppHost API, and a C# change regenerates deterministically every time. The external-ingress and min-replica gaps from the last section both close in the AppHost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_Http&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-http"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHostStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostStorage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithExternalHttpEndpoints&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                       &lt;span class="c1"&gt;// flips ingress external: true&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PublishAsAzureContainerApp&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;infra&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MinReplicas&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// allow scale-to-zero&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ConfigureInfrastructure&lt;/code&gt; reaches the backing resources the same way, for firewall rules, network ACLs, or a private endpoint added with &lt;code&gt;infra.Add(new PrivateEndpoint(...))&lt;/code&gt;. The enterprise networking story is largely C#-supported, not Bicep-only: a VNet on a new environment via &lt;code&gt;WithDelegatedSubnet&lt;/code&gt;, existing resources via &lt;code&gt;AsExisting&lt;/code&gt; (same subscription), a custom Log Analytics workspace via &lt;code&gt;WithAzureLogAnalyticsWorkspace&lt;/code&gt;. The gaps that still fall back to hand-authored Bicep are narrow: reconfiguring the VNet or volumes on an &lt;em&gt;existing&lt;/em&gt; ACA environment, cross-subscription existing references, and the &lt;code&gt;AcrPull&lt;/code&gt; role assignment when you bring your own registry pull identity.&lt;/p&gt;

&lt;p&gt;One change is mandatory rather than optional, and the Part 3 sample already has it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAzureContainerAppEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"aca-env"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aspire 9.4 removed the hybrid mode where &lt;code&gt;azd&lt;/code&gt; silently owned the Container Apps environment for you. Without this line, &lt;code&gt;aspire publish&lt;/code&gt; and &lt;code&gt;azd&lt;/code&gt; have no compute target to publish into. Older tutorials that call &lt;code&gt;PublishAsAzureContainerApp()&lt;/code&gt; with no environment declared predate that change and no longer work as written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A management-group tag policy put the C#-first advice through a live test.&lt;/strong&gt; The subscription this article deployed to denies any deployment whose resources are missing four tags (&lt;code&gt;cost-center&lt;/code&gt;, &lt;code&gt;owner&lt;/code&gt;, &lt;code&gt;environment&lt;/code&gt;, &lt;code&gt;project&lt;/code&gt;) with values from allowed lists, and the first &lt;code&gt;azd provision&lt;/code&gt; failed on exactly that. The temptation at that moment is to edit tags into ten generated modules. The fix that holds is one C# class. An &lt;code&gt;InfrastructureResolver&lt;/code&gt; participates in &lt;code&gt;Azure.Provisioning&lt;/code&gt;'s Bicep generation and visits every construct, which means it reaches resources that have no first-class customisation hook of their own, like the per-function identities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Azure.Provisioning&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Azure.Provisioning.Primitives&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AzureTagResolver&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;InfrastructureResolver&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;RequiredTags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"cost-center"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"cc-1234"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"platform-team"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"project"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"aspire-demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;ResolveProperties&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProvisionableConstruct&lt;/span&gt; &lt;span class="n"&gt;construct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ProvisioningBuildOptions&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ResolveProperties&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;construct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Not every construct exposes Tags (role assignments don't), hence the probe.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;construct&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ProvisionableResource&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt;
            &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetType&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;GetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Tags"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;GetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;BicepDictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Tags bound to a bicep expression reject item assignment; those resources&lt;/span&gt;
        &lt;span class="c1"&gt;// get a literal rebind in ConfigureInfrastructure instead (below).&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;bicepValue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IBicepValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bicepValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsOutput&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;bicepValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Expression&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;bicepValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kind&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;BicepValueKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;RequiredTags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// ResolveProperties runs until the construct graph stabilises;&lt;/span&gt;
            &lt;span class="c1"&gt;// re-assigning an existing entry keeps it dirty and never converges.&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ContainsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Registered once on the AppHost builder, it stamps every taggable resource in every module and survives every regeneration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configure&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AzureProvisioningOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProvisioningBuildOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InfrastructureResolvers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AzureTagResolver&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two guard clauses in the resolver each cost real debugging time. The idempotency one first: &lt;code&gt;ResolveProperties&lt;/code&gt; runs repeatedly until the construct graph stops changing, and unconditionally re-assigning a tag keeps the graph dirty, so generation never converges. The error you get for that is misleading: &lt;code&gt;azd infra gen&lt;/code&gt; fails with &lt;code&gt;apphost-manifest.json: no such file or directory&lt;/code&gt;, because generation never got far enough to write the manifest. The expression guard second: the Container Apps environment module routes its resources' tags through a module-level &lt;code&gt;tags&lt;/code&gt; parameter, and assigning into a &lt;code&gt;Tags&lt;/code&gt; dictionary bound to an expression throws &lt;code&gt;Cannot assign to Tags, the dictionary is an expression or output only&lt;/code&gt;. The resolver skips those resources, and a &lt;code&gt;ConfigureInfrastructure&lt;/code&gt; callback rebinds their &lt;code&gt;Tags&lt;/code&gt; to a literal instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAzureContainerAppEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"aca-env"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureInfrastructure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;infra&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;infra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetProvisionableResources&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;taggable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OfType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ContainerAppManagedEnvironment&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="n"&gt;Cast&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ProvisionableResource&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OfType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OperationalInsightsWorkspace&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OfType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;UserAssignedIdentity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;());&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;taggable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetType&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;GetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Tags"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;SetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;BicepDictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"cost-center"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"cc-1234"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="c1"&gt;// ...the same four tags&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After those two fixes, every resource the policy evaluates carried the four tags: ten infrastructure resources and four container apps, zero edits to generated files. The detour also mapped where C# ownership ends. The resource group is declared in azd's own &lt;code&gt;main.bicep&lt;/code&gt;, which no AppHost API reaches; tag it by hand and the next &lt;code&gt;azd infra gen&lt;/code&gt; deletes the edit. (Role assignments carry no tags at all; ARM does not support tags on them.) If your policy evaluates resource groups too, that is a policy exemption conversation, or a reason for the lane below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or generate once, then own it.&lt;/strong&gt; When you have a customization with no C# API, the supported escape hatch is to run the generator a single time and stop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;azd infra gen
&lt;span class="go"&gt;Generating infrastructure
Analyzing Aspire Application (this might take a moment...)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That writes the &lt;code&gt;infra/&lt;/code&gt; tree into your repo. From there you commit it, manage it by hand, and stop regenerating. The compose-generate docs bless this workflow. The failure mode to avoid is the middle ground: editing the generated Bicep &lt;em&gt;and&lt;/em&gt; continuing to run the generator, which wipes your edits on the next pass. Pick one lane: customise in C# and keep regenerating, or generate once and own the files.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Actions CI/CD
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;azd pipeline config&lt;/code&gt; wires the repository to Azure and drops a workflow into &lt;code&gt;.github/workflows/&lt;/code&gt;. It creates an app registration, assigns a role, configures federated identity, and pushes the generated &lt;code&gt;azure-dev.yml&lt;/code&gt;. OIDC is the default, which is the detail that matters: there is no client secret stored anywhere. The identity values land as repository &lt;strong&gt;variables&lt;/strong&gt; (&lt;code&gt;AZURE_CLIENT_ID&lt;/code&gt;, &lt;code&gt;AZURE_TENANT_ID&lt;/code&gt;, &lt;code&gt;AZURE_SUBSCRIPTION_ID&lt;/code&gt;), not as the legacy &lt;code&gt;AZURE_CREDENTIALS&lt;/code&gt; JSON secret.&lt;/p&gt;

&lt;p&gt;The generated workflow is closer to production-ready than its reputation suggests. It does not run &lt;code&gt;azd up&lt;/code&gt;. It already runs provision and deploy as separate steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install azd&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Azure/setup-azd@v2&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install .NET for Aspire&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-dotnet@v4&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;dotnet-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.x&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Log in with Azure (Federated Credentials)&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azd auth login --client-id "$AZURE_CLIENT_ID" --federated-credential-provider github --tenant-id "$AZURE_TENANT_ID"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Provision&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azd provision --no-prompt&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azd deploy --no-prompt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the default does &lt;em&gt;not&lt;/em&gt; do is gate or separate the cadence. Both steps run in the same job, on every push, unreviewed. The advisable edit is to split by cadence and approval: move &lt;code&gt;azd provision&lt;/code&gt; into its own job gated behind a GitHub Environment with required reviewers (infra changes are rare and want a human), and keep &lt;code&gt;azd deploy&lt;/code&gt; as the frequent job on app commits. Each job needs its own &lt;code&gt;id-token: write&lt;/code&gt; and its own &lt;code&gt;azd auth login&lt;/code&gt;, because OIDC tokens and &lt;code&gt;azd&lt;/code&gt; login state do not cross job boundaries. This is a hand edit, not a flag, and it ties straight back to the provision/deploy split from earlier, now enforced by the pipeline.&lt;/p&gt;

&lt;p&gt;For configuration, &lt;code&gt;azd&lt;/code&gt; stores per-environment values in &lt;code&gt;.azure/&amp;lt;env&amp;gt;/.env&lt;/code&gt; and projects them into pipeline variables. Custom variables and secrets go under a &lt;code&gt;pipeline:&lt;/code&gt; block in &lt;code&gt;azure.yaml&lt;/code&gt;, each matching an environment key, and you rerun &lt;code&gt;azd pipeline config&lt;/code&gt; after editing. Key Vault references via &lt;code&gt;azd env set-secret&lt;/code&gt; (&lt;code&gt;akvs://...&lt;/code&gt;) keep rotation out of the pipeline when stored as variables; secured Bicep parameters with no default get bundled into a single &lt;code&gt;AZD_INITIAL_ENVIRONMENT_CONFIG&lt;/code&gt; secret on the provision step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The boundary is the whole story. Aspire generates real, readable Bicep that wires identities, roles, and connections for the four integrations it supports, and it stops at a set of edges you can name: connection-name matching, external ingress, scale-to-zero, and everything outside those four integrations. Read the generated infrastructure once and you know which side of that line each piece of your app is on.&lt;/p&gt;

&lt;p&gt;That closes Series 3. Part 1 made the AppHost the source of truth, Part 2 declared the Azure services as resources, and Part 3 deployed the graph and found where the generator stops.&lt;/p&gt;

&lt;p&gt;Do you let Aspire generate your Azure infrastructure and customise it in C#, or do you generate once and own the Bicep by hand from there?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>aspire</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Azure Services as Aspire Resources: Service Bus, Storage, and Redis</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 05 Jun 2026 06:17:53 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/azure-services-as-aspire-resources-service-bus-storage-and-redis-2260</link>
      <guid>https://dev.to/martin_oehlert/azure-services-as-aspire-resources-service-bus-storage-and-redis-2260</guid>
      <description>&lt;p&gt;A &lt;code&gt;[ServiceBusTrigger("orders", Connection = "messaging")]&lt;/code&gt; attribute doesn't hold a connection string. It holds the name &lt;code&gt;"messaging"&lt;/code&gt;, and something has to resolve that name to a real connection in every environment the function runs in. Part 1 moved one connection (host storage) out of &lt;code&gt;local.settings.json&lt;/code&gt; and into the AppHost. The question this part answers is whether the same move holds for the connections you actually choose: a Service Bus namespace, a second storage account, a Redis cache, three services that in a traditional Functions app are three strings a developer pastes per machine and hopes match production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The connection string problem, widened
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/AspireDemo" rel="noopener noreferrer"&gt;companion sample&lt;/a&gt; from Part 1 had two Functions projects sharing one host-storage emulator. That covered the connection the runtime needs for its own bookkeeping, the one injected as &lt;code&gt;AzureWebJobsStorage&lt;/code&gt;. It didn't cover the connections your code triggers on.&lt;/p&gt;

&lt;p&gt;Part 2 adds a third worker, &lt;code&gt;OrderProcessor.ServiceBus&lt;/code&gt;, that consumes a Service Bus queue, dedupes against Redis, and writes a receipt blob to a storage account that isn't host storage. That one function touches three connections you name yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConfirmOrderFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ConfirmOrderFunction&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;OrderValidator&lt;/span&gt; &lt;span class="n"&gt;validator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IConnectionMultiplexer&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ConfirmOrder&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;BlobOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"receipts/{OrderId}.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"receipts"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ConfirmOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ServiceBusTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;OrderMessage&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffpz620n0ezit753hhe8a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffpz620n0ezit753hhe8a.png" alt="End-to-end path: a Service Bus message flows through the trigger, into the Redis idempotency gate, and out to a blob receipt; a duplicate delivery short-circuits to null." width="712" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In a traditional setup each of those (&lt;code&gt;messaging&lt;/code&gt;, &lt;code&gt;receipts&lt;/code&gt;, and the Redis connection the multiplexer reads) is a string in &lt;code&gt;local.settings.json&lt;/code&gt;, copied from the portal, kept current by hand on every machine and in every environment. Three names, three places to drift. The fix is the one from Part 1, applied three times: declare each service once in the AppHost as a &lt;strong&gt;resource&lt;/strong&gt;, hand it to the Functions project by reference, and let Aspire compute the connection value per environment. What stays is the short name. &lt;code&gt;"messaging"&lt;/code&gt; appears in the AppHost and on the trigger, and those two have to agree. The value behind the name stops being something you maintain.&lt;/p&gt;

&lt;p&gt;The rest of this article is those three declarations and what each one resolves to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service Bus as an Aspire resource
&lt;/h2&gt;

&lt;p&gt;The package is &lt;a href="https://www.nuget.org/packages/Aspire.Hosting.Azure.ServiceBus" rel="noopener noreferrer"&gt;&lt;code&gt;Aspire.Hosting.Azure.ServiceBus&lt;/code&gt;&lt;/a&gt;. Two lines in &lt;code&gt;AppHost.cs&lt;/code&gt; declare the namespace and a queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;messaging&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAzureServiceBus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RunAsEmulator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;messaging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddServiceBusQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddAzureServiceBus("messaging")&lt;/code&gt; returns the namespace resource. Children come from &lt;code&gt;AddServiceBusQueue&lt;/code&gt; / &lt;code&gt;AddServiceBusTopic&lt;/code&gt; / &lt;code&gt;AddServiceBusSubscription&lt;/code&gt; (the older &lt;code&gt;AddQueue&lt;/code&gt; / &lt;code&gt;AddTopic&lt;/code&gt; / &lt;code&gt;AddSubscription&lt;/code&gt; names were obsoleted in 9.1, so a 9.x snippet from the Learn API reference won't compile against 13.x).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RunAsEmulator()&lt;/code&gt; is the line that earns the section. Locally it starts the &lt;a href="https://learn.microsoft.com/azure/service-bus-messaging/overview-emulator" rel="noopener noreferrer"&gt;Service Bus emulator&lt;/a&gt; as containers Aspire owns. In publish mode it's a no-op, so the same two lines provision a real namespace under &lt;code&gt;azd&lt;/code&gt;. One declaration, two resolutions.&lt;/p&gt;

&lt;p&gt;Two things about that emulator are worth knowing before you run it.&lt;/p&gt;

&lt;p&gt;First, it isn't one container, it's two. The emulator needs a SQL backend, so Aspire pulls &lt;code&gt;mcr.microsoft.com/azure-messaging/servicebus-emulator:2.0.0&lt;/code&gt; plus &lt;code&gt;mcr.microsoft.com/mssql/server:2022-latest&lt;/code&gt;, generates the SQL &lt;code&gt;sa&lt;/code&gt; password, sets &lt;code&gt;ACCEPT_EULA=Y&lt;/code&gt; on both, and injects the SQL connection into the emulator. You write one line; Aspire coordinates two containers and the secret between them. No &lt;code&gt;.env&lt;/code&gt; file to edit.&lt;/p&gt;

&lt;p&gt;Second, the emulator doesn't create entities at runtime. It reads a &lt;code&gt;Config.json&lt;/code&gt; at startup and provisions exactly what's in it. Aspire generates that file from your &lt;code&gt;AddServiceBusQueue("orders")&lt;/code&gt; declaration and mounts it, so the queue exists when the worker connects. On the live run the emulator logged &lt;code&gt;Creating queue: orders&lt;/code&gt; then &lt;code&gt;Emulator Service is Successfully Up!&lt;/code&gt;. If you need topics, subscriptions, or rules the emulator config supports but the fluent API doesn't reach, &lt;code&gt;WithConfigurationFile&lt;/code&gt; and &lt;code&gt;WithConfiguration&lt;/code&gt; are the escape hatches.&lt;/p&gt;

&lt;p&gt;The trigger side is where Service Bus pays off the resource model, because Service Bus is one of the four integrations Aspire auto-wires into the Functions binding system. On the AppHost you hand the namespace to the worker by reference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_ServiceBus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-sb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHostStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostStorage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messaging&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second argument to &lt;code&gt;WithReference&lt;/code&gt; is the connection name. The trigger names the same string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ServiceBusTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;OrderMessage&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole contract. Aspire computes the connection value (the emulator's local endpoint now, a real namespace under &lt;code&gt;azd&lt;/code&gt; later) and injects it under &lt;code&gt;messaging&lt;/code&gt;; the trigger resolves &lt;code&gt;messaging&lt;/code&gt; and binds. The name matches because you typed it twice, not because anything derives it. Omit the second &lt;code&gt;WithReference&lt;/code&gt; argument and it defaults to the resource name, which here is also &lt;code&gt;messaging&lt;/code&gt;, so it would resolve either way; the explicit form is clearer about what the contract is. The worker needs &lt;a href="https://www.nuget.org/packages/Microsoft.Azure.Functions.Worker.Extensions.ServiceBus" rel="noopener noreferrer"&gt;&lt;code&gt;Microsoft.Azure.Functions.Worker.Extensions.ServiceBus&lt;/code&gt;&lt;/a&gt; (5.24.0 against Worker 2.x) for the trigger attribute to exist.&lt;/p&gt;

&lt;p&gt;Be honest with your team about the emulator's limits. Microsoft labels it dev/test only, production use is explicitly discouraged. It speaks AMQP over TCP, and it doesn't persist messages across a restart; entities re-provision from &lt;code&gt;Config.json&lt;/code&gt;, but in-flight messages are gone. On Apple Silicon both images are amd64-only and run under Rosetta. That sounds like a caveat but it's the good kind: on the author's M-series machine the full message → trigger → blob path ran end to end with no special Docker flags beyond having Rosetta emulation available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage beyond host storage
&lt;/h2&gt;

&lt;p&gt;Part 1 used one storage resource for host bookkeeping. Application data shouldn't share it. The receipt this worker writes is your data, not the runtime's lease blobs and scaling state, so it gets its own resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;appStorage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAzureStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"app-storage"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RunAsEmulator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;receipts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddBlobs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"receipts"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RunAsEmulator()&lt;/code&gt; here is &lt;a href="https://learn.microsoft.com/azure/storage/common/storage-use-azurite" rel="noopener noreferrer"&gt;Azurite&lt;/a&gt;, pulled as &lt;code&gt;mcr.microsoft.com/azure-storage/azurite:3.35.0&lt;/code&gt; and started and stopped with the AppHost. There's no &lt;code&gt;npm install -g azurite&lt;/code&gt;, no second terminal running &lt;code&gt;azurite --silent&lt;/code&gt;, no hand-written &lt;code&gt;devstoreaccount1&lt;/code&gt; string. The prerequisite step from every Functions README becomes a line in a project the team checks in.&lt;/p&gt;

&lt;p&gt;One storage resource fans out to all three services. &lt;code&gt;AddBlobs&lt;/code&gt; and &lt;code&gt;AddQueues&lt;/code&gt; give you the blob and queue endpoints; &lt;code&gt;AddTables&lt;/code&gt; gives you the table service. Note the plural: there's no singular &lt;code&gt;AddTable&lt;/code&gt; in 13.x, and Aspire 9.4 moved these to top-level calls on the storage resource (&lt;code&gt;AddBlobContainer&lt;/code&gt;, &lt;code&gt;Add*ServiceClient&lt;/code&gt; on the client side), so older snippets miss the rename. Blob and Queue auto-wire into triggers the same way Service Bus does; Tables do not, and need the &lt;code&gt;WithEnvironment&lt;/code&gt; form the last section covers.&lt;/p&gt;

&lt;p&gt;The blob output binding is auto-wired by the same &lt;code&gt;WithReference&lt;/code&gt; pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_ServiceBus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-sb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHostStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostStorage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messaging&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;receipts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"receipts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WithReference(receipts, "receipts")&lt;/code&gt; injects the connection the &lt;code&gt;[BlobOutput(... Connection = "receipts")]&lt;/code&gt; attribute names. The function returns an &lt;code&gt;Order&lt;/code&gt;, and Aspire serializes it to &lt;code&gt;receipts/{OrderId}.json&lt;/code&gt;. The &lt;code&gt;{OrderId}&lt;/code&gt; token in that path is worth a beat: it resolves from the Service Bus trigger's POCO, the &lt;code&gt;OrderMessage&lt;/code&gt; that fired the function, not from any injected client. On the live run a message with &lt;code&gt;OrderId&lt;/code&gt; &lt;code&gt;w23-001&lt;/code&gt; produced &lt;code&gt;receipts/w23-001.json&lt;/code&gt; containing the serialized order. The blob binding read a value off the message that triggered it, across two different Azure services, with no glue code between them.&lt;/p&gt;

&lt;p&gt;Ports are mapped dynamically, so never hardcode &lt;code&gt;127.0.0.1:10000&lt;/code&gt;; read the connection from the injected value. Azurite is in-memory by default. If you want receipts to survive a restart, &lt;code&gt;WithDataVolume()&lt;/code&gt; or &lt;code&gt;WithDataBindMount()&lt;/code&gt; opts into persistence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redis as an Aspire resource
&lt;/h2&gt;

&lt;p&gt;Redis is the resource that breaks the pattern, and the break is the useful part of the section. The declaration looks like the others:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://learn.microsoft.com/dotnet/aspire/caching/stackexchange-redis-integration" rel="noopener noreferrer"&gt;&lt;code&gt;AddRedis&lt;/code&gt;&lt;/a&gt; (from &lt;code&gt;Aspire.Hosting.Redis&lt;/code&gt;) runs a local Redis container with health checks; at publish it deploys containerized Redis on Container Apps. For a managed cache in production the current API is &lt;code&gt;AddAzureManagedRedis&lt;/code&gt; (Azure Managed Redis, Entra ID auth by default). Don't reach for &lt;code&gt;AddAzureRedis&lt;/code&gt;, &lt;code&gt;PublishAsAzureRedis&lt;/code&gt;, or &lt;code&gt;.RunAsContainer()&lt;/code&gt; on the Azure Redis resource; all three are &lt;code&gt;[Obsolete]&lt;/code&gt; in 13.x.&lt;/p&gt;

&lt;p&gt;The break is that &lt;strong&gt;Redis is not auto-wired into the Functions trigger system&lt;/strong&gt;. The four integrations that feed triggers and bindings are Blob, Queue, Event Hubs, and Service Bus. Redis isn't one of them, which means &lt;code&gt;WithReference(cache)&lt;/code&gt; on its own does not make a Redis trigger resolve. What &lt;code&gt;WithReference(cache)&lt;/code&gt; does serve is the Aspire client integration: register &lt;code&gt;IConnectionMultiplexer&lt;/code&gt; in the worker with one line and read the cache from code triggered by something else.&lt;/p&gt;

&lt;p&gt;That's the path this sample uses. In &lt;code&gt;Program.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedisClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cache"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the function takes &lt;code&gt;IConnectionMultiplexer&lt;/code&gt; by constructor injection and uses Redis as an idempotency gate, because Service Bus delivers at-least-once and a retry can replay the same order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetDatabase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;firstDelivery&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StringSetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;$"orders:seen:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;When&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotExists&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;firstDelivery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Duplicate order {OrderId}, skipping receipt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail from the live run is worth a callout, because it's the kind of thing that costs an afternoon if you hit it raw. Aspire's 13.x Redis container ships with TLS and auth on by default: it runs &lt;code&gt;redis-server --requirepass &amp;lt;generated&amp;gt; --tls-port 6379 ...&lt;/code&gt; with Aspire-issued certs. A naive &lt;code&gt;redis-cli&lt;/code&gt; against it gets "Connection reset by peer" because it spoke plaintext to a TLS port. &lt;code&gt;AddRedisClient("cache")&lt;/code&gt; handles the handshake and the generated password from the injected connection string, so the worker code stays at &lt;code&gt;IConnectionMultiplexer&lt;/code&gt; and &lt;code&gt;GetDatabase()&lt;/code&gt;. Hand-rolling the connection would mean configuring TLS and the secret yourself.&lt;/p&gt;

&lt;p&gt;If you genuinely want to trigger on Redis (Pub/Sub, List, or Stream), the &lt;a href="https://www.nuget.org/packages/Microsoft.Azure.Functions.Worker.Extensions.Redis" rel="noopener noreferrer"&gt;&lt;code&gt;Microsoft.Azure.Functions.Worker.Extensions.Redis&lt;/code&gt;&lt;/a&gt; extension exists, but two things apply. The trigger attribute's first argument is an app-setting name, so you wire the connection with the &lt;code&gt;WithEnvironment&lt;/code&gt; form from the next section rather than &lt;code&gt;WithReference&lt;/code&gt;. And the Redis triggers run only on Elastic Premium or dedicated App Service plans, not Consumption or Flex Consumption. For the serverless default, the &lt;code&gt;IConnectionMultiplexer&lt;/code&gt;-via-DI path above is the one that works on every plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Aspire resolves dev versus production
&lt;/h2&gt;

&lt;p&gt;Three declarations, three behaviors at the trigger boundary. The rule underneath them is exact, so state it exactly: Aspire auto-wires four integrations into the Functions binding system through &lt;code&gt;WithReference&lt;/code&gt;, and those four are Blob Storage, Queue Storage, Event Hubs, and Service Bus. For one of those, &lt;code&gt;WithReference(resource, "name")&lt;/code&gt; is the whole wiring. For anything else (Redis, Cosmos, SQL, Tables, a custom service), you set the env var yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_ServiceBus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-sb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"RedisConnection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectionStringExpression&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line per non-auto-wired resource. Real, but bounded, and the same shape every time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkqwbs7jt3rl67bko3uru.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkqwbs7jt3rl67bko3uru.png" alt="Aspire auto-wires four integrations (Service Bus, Blob, Queue, Event Hubs) into Functions bindings through WithReference; every other resource takes an explicit WithEnvironment line." width="712" height="282"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What the name buys you is single-sourcing of the connection &lt;em&gt;value&lt;/em&gt;, not the name. &lt;code&gt;WithReference(messaging, "messaging")&lt;/code&gt; and &lt;code&gt;[ServiceBusTrigger(Connection = "messaging")]&lt;/code&gt; agree because you wrote &lt;code&gt;"messaging"&lt;/code&gt; in both places. Aspire computes what that name resolves to and how that changes between local and published; it does not derive the name or check that the two spellings match. The one fully-automatic name in the whole system is &lt;code&gt;AzureWebJobsStorage&lt;/code&gt;, injected by &lt;code&gt;AddAzureFunctionsProject&amp;lt;T&amp;gt;()&lt;/code&gt; itself. Every other name is a contract you keep in code.&lt;/p&gt;

&lt;p&gt;The resolution itself keys off execution context, not config files. &lt;code&gt;RunAsEmulator()&lt;/code&gt; (Service Bus, Storage) and the local container (&lt;code&gt;AddRedis&lt;/code&gt;) are what you get when you run the AppHost. Run &lt;code&gt;azd&lt;/code&gt; in publish mode and &lt;code&gt;RunAsEmulator()&lt;/code&gt; becomes a no-op, so the same declaration provisions the real Azure resource. There is no &lt;code&gt;appsettings.Production.json&lt;/code&gt; toggling between them; the decision is whether you're running or publishing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr61x267l9nn9bf7zcpkx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr61x267l9nn9bf7zcpkx.png" alt="One RunAsEmulator declaration resolves two ways: local emulator containers when you run the AppHost, a provisioned Azure namespace when you publish with azd." width="712" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The connection's &lt;em&gt;shape&lt;/em&gt; flips on publish, and this is the part that's easy to miss. Provisioned Azure resources default to identity-based connections, so a published Storage connection is a &lt;code&gt;__serviceUri&lt;/code&gt; and a Service Bus connection is a &lt;code&gt;__fullyQualifiedNamespace&lt;/code&gt;, not a key-bearing string. For the four auto-wired integrations Aspire emits the right suffix for you. For the escape-hatch resources you write that branch. A Tables service is the clean example: it's Storage, but not one of the auto-wired four, so locally the binding reads a connection string and on Azure it needs the identity-based service URI. You switch the env-var suffix on the execution context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddTables&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ledger"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_ServiceBus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-sb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsPublishMode&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"Ledger__serviceUri"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Ledger"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectionStringExpression&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Locally that injects the Azurite table endpoint under the name &lt;code&gt;Ledger&lt;/code&gt;; on publish the name becomes &lt;code&gt;Ledger__serviceUri&lt;/code&gt; pointing at the provisioned account, and the Azure SDK reads managed identity off the suffix. The auto-wired four run this same switch for you; for everything else, this one line is it.&lt;/p&gt;

&lt;p&gt;The default publish target is Azure Container Apps, which is GA; publishing as a real Function App needs &lt;code&gt;Aspire.Hosting.Azure.AppService&lt;/code&gt;, still preview as of May 2026. Part 3 takes the publish path apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three resources, one source of truth
&lt;/h2&gt;

&lt;p&gt;The migration from Part 1 is additive again. The AppHost gains four declarations (a Service Bus namespace and its queue, a second storage resource, a Redis cache) and three &lt;code&gt;WithReference&lt;/code&gt; lines on one new Functions project. The worker gains one &lt;code&gt;AddRedisClient("cache")&lt;/code&gt; call. Those few lines start five containers when you run the AppHost: two Azurite instances (host storage and &lt;code&gt;app-storage&lt;/code&gt;), the Service Bus emulator with its &lt;code&gt;mssql/server:2022-latest&lt;/code&gt; backend, and Redis 8.6. You declared three services; Aspire pulled the images, generated the secrets between them, and wired every connection.&lt;/p&gt;

&lt;p&gt;No connection strings moved into &lt;code&gt;local.settings.json&lt;/code&gt;, because that's the file the whole exercise is removing as a source of truth. Keep &lt;code&gt;FUNCTIONS_WORKER_RUNTIME&lt;/code&gt; in it and let Aspire own the rest; if a value is set in both, Aspire wins. One line is worth deleting on the way out: the Functions template seeds &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; to &lt;code&gt;UseDevelopmentStorage=true&lt;/code&gt;, which starts its own Azurite and can race the one Aspire owns. Remove it and let &lt;code&gt;AddAzureFunctionsProject&amp;lt;T&amp;gt;()&lt;/code&gt; inject host storage instead.&lt;/p&gt;

&lt;p&gt;The honest scope: the Service Bus emulator is dev/test only and runs under Rosetta on Apple Silicon, Redis isn't auto-wired and its trigger extension is Premium-plan only, and identity-based connections on publish need one branch for the resources outside the auto-wired four. None of that is a blocker, but a teammate hits each one eventually, so put them in the README, not the postmortem.&lt;/p&gt;

&lt;p&gt;Part 3 takes this AppHost to Azure: what &lt;code&gt;azd&lt;/code&gt; provisions, why Container Apps is the default target, and what Aspire generates under the hood.&lt;/p&gt;

&lt;p&gt;Of these three, which do you configure by hand today, a Service Bus connection per environment, a second storage account, or a Redis cache, and which one would you move into the AppHost first?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>aspire</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Getting Started with .NET Aspire for Azure Functions</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 29 May 2026 06:35:04 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/getting-started-with-net-aspire-for-azure-functions-2g88</link>
      <guid>https://dev.to/martin_oehlert/getting-started-with-net-aspire-for-azure-functions-2g88</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;.NET Aspire for Azure Functions Developers&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;Prerequisite: &lt;a href="https://dev.to/martin_oehlert/series/38960"&gt;Azure Functions for .NET Developers&lt;/a&gt; (Parts 1-9)&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1: Getting Started with .NET Aspire for Azure Functions&lt;/strong&gt; &lt;em&gt;(you are here)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 2: Azure Services as Aspire Resources: Service Bus, Storage, and Redis &lt;em&gt;(coming)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 3: Deploying .NET Aspire Apps to Azure: AZD, ACA, and What Aspire Generates &lt;em&gt;(coming)&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;A new developer joins, hits F5, and the Function fails on startup because their &lt;code&gt;local.settings.json&lt;/code&gt; names the storage emulator differently from yours. The question isn't "what should they have typed" but "why is the configuration source of truth a per-machine JSON file in the first place." The &lt;a href="https://learn.microsoft.com/dotnet/aspire/get-started/aspire-overview" rel="noopener noreferrer"&gt;.NET Aspire&lt;/a&gt; AppHost moves that source of truth into a project you check into source control, so the storage emulator, queues, and the Functions app itself all start from one &lt;code&gt;dotnet run&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The drift surface
&lt;/h2&gt;

&lt;p&gt;If you've ever helped a teammate get the &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/ProjectOrganizationDemo" rel="noopener noreferrer"&gt;ProjectOrganizationDemo&lt;/a&gt; sample running, you've seen the surface. Two Function Apps (&lt;code&gt;OrderProcessor.Http&lt;/code&gt; and &lt;code&gt;OrderProcessor.Queue&lt;/code&gt;) share one core library. Each app ships a &lt;code&gt;local.settings.json.example&lt;/code&gt; with two values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"IsEncrypted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Values"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AzureWebJobsStorage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UseDevelopmentStorage=true"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"FUNCTIONS_WORKER_RUNTIME"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dotnet-isolated"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To run the sample end to end you start three processes: Azurite for the emulator, &lt;code&gt;func start&lt;/code&gt; on the HTTP app, &lt;code&gt;func start&lt;/code&gt; on the queue worker. Each developer rebuilds the same configuration on their machine. The two &lt;code&gt;local.settings.json&lt;/code&gt; files are git-ignored, so the source of truth is whatever lives in two text files on every developer's laptop. Multiply by the number of joiners and the time spent on "why isn't my function starting" stops being a one-off.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;[QueueTrigger("orders")] OrderMessage message&lt;/code&gt; attribute in &lt;code&gt;ProcessOrderFunction.cs&lt;/code&gt; doesn't name a &lt;code&gt;Connection&lt;/code&gt;. It falls back to &lt;code&gt;AzureWebJobsStorage&lt;/code&gt;, which is the connection string both apps duplicate. Every storage account, queue, and cache the Functions runtime needs has to be present, by name, in the JSON file the developer remembers to keep up to date.&lt;/p&gt;

&lt;p&gt;The AppHost isn't here to make &lt;code&gt;local.settings.json&lt;/code&gt; shorter. It's here to make it stop being the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Aspire, and why for Functions
&lt;/h2&gt;

&lt;p&gt;A plain ASP.NET API is one &lt;code&gt;dotnet run&lt;/code&gt;. A Functions app isn't. Before any of your business logic executes it needs three things that aren't your code: the &lt;code&gt;func&lt;/code&gt; host (the worker is launched behind it, not as a bare executable), an emulator standing in for &lt;code&gt;AzureWebJobsStorage&lt;/code&gt;, and every trigger's connection resolved by name out of configuration. For a single web service that overhead barely registers. For the two-worker sample above it's the entire local-dev surface, and it's exactly what &lt;a href="https://learn.microsoft.com/dotnet/aspire/get-started/aspire-overview" rel="noopener noreferrer"&gt;.NET Aspire&lt;/a&gt; is built to absorb. Aspire is three things at once: an orchestration model (the AppHost), a set of typed integration packages, and a local dashboard for logs and traces. The orchestration is the part that earns its place here.&lt;/p&gt;

&lt;p&gt;The honest comparison is Aspire against the two things teams already reach for, not against nothing. The first is a shell of terminals. Today you open three: Azurite in one, &lt;code&gt;func start&lt;/code&gt; on the HTTP app in a second, &lt;code&gt;func start&lt;/code&gt; on the queue worker in a third, started in that order because the workers need the emulator already listening. Three log streams in three windows, three &lt;code&gt;local.settings.json&lt;/code&gt; files feeding them, and no view of a message as it crosses from the HTTP app into the queue worker. One &lt;code&gt;dotnet run&lt;/code&gt; on the AppHost replaces all three processes, and because both workers report to the same dashboard you get the thing the three-terminal setup structurally can't produce: a single trace that follows one &lt;code&gt;POST /api/orders&lt;/code&gt; through the HTTP app, across the queue, and into the worker that dequeues it.&lt;/p&gt;

&lt;p&gt;The second is &lt;code&gt;docker-compose&lt;/code&gt;. It can model the same set: an Azurite service, two Function containers, a shared network. What it can't do is stay in .NET. Each Function project needs a Dockerfile and an image rebuild (or a mounted volume) on every change; connection strings live as literal strings in YAML or an &lt;code&gt;.env&lt;/code&gt; file, the same drift surface in a different format; service wiring is container DNS, which has nothing to do with the &lt;code&gt;Connection&lt;/code&gt; name the Functions runtime actually resolves; and the dashboard, traces, and health checks aren't part of the deal. The AppHost is a .NET project that references your Functions projects directly. The resource graph is C# the compiler checks, the connection a worker reads is computed from a container Aspire owns, and the same description is what later drives deployment. You trade a YAML file the build can't verify for a project it can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AppHost as composition root
&lt;/h2&gt;

&lt;p&gt;The Aspire AppHost is a separate .NET project that boots your distributed app. It declares the resources (storage accounts, queues, Service Bus, your Functions projects) and wires them together. One &lt;code&gt;dotnet run&lt;/code&gt; on the AppHost starts the whole set.&lt;/p&gt;

&lt;p&gt;You don't install a workload. Aspire dropped the &lt;code&gt;dotnet workload install aspire&lt;/code&gt; step in 9.0 and hasn't brought it back in 13.x. You install the project templates once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new &lt;span class="nb"&gt;install &lt;/span&gt;Aspire.ProjectTemplates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two templates you need are &lt;code&gt;aspire-apphost&lt;/code&gt; (the orchestration project) and &lt;code&gt;aspire-servicedefaults&lt;/code&gt; (a class library with the OpenTelemetry and health-check wiring you call from your Functions project). The AppHost does the orchestration; the service-defaults library is the one line of worker code that routes telemetry to the dashboard, and a later section wires it in.&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;dotnet new aspire-apphost -n AspireDemo.AppHost&lt;/code&gt; gives you a project file with no &lt;code&gt;Microsoft.NET.Sdk&lt;/code&gt; base and the Aspire SDK pinned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Project&lt;/span&gt; &lt;span class="na"&gt;Sdk=&lt;/span&gt;&lt;span class="s"&gt;"Aspire.AppHost.Sdk/13.3.5"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;PropertyGroup&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;OutputType&amp;gt;&lt;/span&gt;Exe&lt;span class="nt"&gt;&amp;lt;/OutputType&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;TargetFramework&amp;gt;&lt;/span&gt;net10.0&lt;span class="nt"&gt;&amp;lt;/TargetFramework&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;IsAspireHost&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/IsAspireHost&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;ItemGroup&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"Aspire.Hosting.Azure.Functions"&lt;/span&gt; &lt;span class="na"&gt;Version=&lt;/span&gt;&lt;span class="s"&gt;"13.3.5"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/ItemGroup&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Project&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three notes. First, Aspire's project SDK replaces the .NET SDK base. There's no &lt;code&gt;Microsoft.NET.Sdk&lt;/code&gt; row; the &lt;code&gt;Aspire.AppHost.Sdk&lt;/code&gt; brings the build targets. Second, the only package reference you need is &lt;a href="https://www.nuget.org/packages/Aspire.Hosting.Azure.Functions" rel="noopener noreferrer"&gt;&lt;code&gt;Aspire.Hosting.Azure.Functions&lt;/code&gt;&lt;/a&gt;. The AppHost runtime pieces come transitively from the SDK. Third, &lt;code&gt;IsAspireHost&lt;/code&gt; is what marks this project for the source generator that produces the strongly-typed &lt;code&gt;Projects.*&lt;/code&gt; references you use in the next section. (In the companion sample the committed csproj is shorter still: central package management drops the explicit &lt;code&gt;Version&lt;/code&gt;, and a &lt;code&gt;Directory.Build.props&lt;/code&gt; supplies the &lt;code&gt;TargetFramework&lt;/code&gt;, so what's left is the SDK line, &lt;code&gt;IsAspireHost&lt;/code&gt;, and one versionless &lt;code&gt;PackageReference&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;The entry point file is &lt;code&gt;AppHost.cs&lt;/code&gt; (the AppHost's entry point, not &lt;code&gt;Program.cs&lt;/code&gt;). The generated default is two lines:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;That's the empty composition. Adding the Functions project is one more line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Functions app as an Aspire resource
&lt;/h2&gt;

&lt;p&gt;The API is &lt;code&gt;AddAzureFunctionsProject&amp;lt;TProject&amp;gt;(name)&lt;/code&gt;. The generic parameter is the strongly-typed project reference (Aspire's source generator produces it once the project is referenced from the AppHost). The string is the name that shows up on the dashboard:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_Queue&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-queue"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't use the generic &lt;code&gt;AddProject&amp;lt;T&amp;gt;&lt;/code&gt; for a Functions project. The &lt;a href="https://learn.microsoft.com/azure/azure-functions/dotnet-aspire-integration" rel="noopener noreferrer"&gt;Microsoft Learn integration page&lt;/a&gt; is blunt about it: the Functions project "can't start properly" if you do. &lt;code&gt;AddAzureFunctionsProject&lt;/code&gt; is the one that knows how to launch the &lt;code&gt;func&lt;/code&gt; host instead of treating the project as a vanilla executable.&lt;/p&gt;

&lt;p&gt;That one line is enough to run a single Functions project. The call provisions host storage (an Azurite emulator container) automatically and sets the &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; environment variable on the worker so existing &lt;code&gt;[QueueTrigger("orders")]&lt;/code&gt; attributes resolve without changes.&lt;/p&gt;

&lt;p&gt;For a multi-project AppHost (the ProjectOrganizationDemo shape: two Functions projects sharing one storage backend), naming the host storage explicitly is the recommended pattern:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;hostStorage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAzureStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"host-storage"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RunAsEmulator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_Http&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-http"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHostStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostStorage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_Queue&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-queue"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHostStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostStorage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WithHostStorage(...)&lt;/code&gt; is GA in &lt;code&gt;Aspire.Hosting.Azure.Functions&lt;/code&gt; 13.1+. It tells Aspire to skip the implicit per-project storage and share the resource you pass in. Locally that collapses to one Azurite container; in publish mode, one storage account.&lt;/p&gt;

&lt;p&gt;Without &lt;code&gt;WithHostStorage&lt;/code&gt;, each &lt;code&gt;AddAzureFunctionsProject&amp;lt;T&amp;gt;&lt;/code&gt; call spins up its own implicit emulator. The Functions still run, but the dashboard shows two storage-emulator rows for what should be one logical concern. For one project that's fine; for two or more, share.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Aspire starts the worker
&lt;/h2&gt;

&lt;p&gt;Aspire never shells out to &lt;code&gt;func start&lt;/code&gt;. The AppHost hands every resource to DCP (the Developer Control Plane), the local orchestrator bundled with the Aspire SDK, and DCP is what actually launches processes and pulls containers. For a Functions project it runs, in effect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotnet run --project OrderProcessor.Queue --no-build --no-launch-profile --port &amp;lt;dcp-assigned&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that otherwise look like trivia follow directly from that command.&lt;/p&gt;

&lt;p&gt;The first is why &lt;code&gt;AddAzureFunctionsProject&amp;lt;T&amp;gt;&lt;/code&gt; exists at all. &lt;code&gt;dotnet run&lt;/code&gt; on a Functions project produces a console executable, not a running Functions host. &lt;code&gt;AddAzureFunctionsProject&amp;lt;T&amp;gt;&lt;/code&gt; is the resource type that knows to boot the isolated worker behind the &lt;code&gt;func&lt;/code&gt; host and inject &lt;code&gt;AzureWebJobsStorage&lt;/code&gt;; the generic &lt;code&gt;AddProject&amp;lt;T&amp;gt;&lt;/code&gt; launches the assembly directly and the worker never starts. That's the concrete reason behind the "can't start properly" warning above.&lt;/p&gt;

&lt;p&gt;The second is &lt;code&gt;--no-build&lt;/code&gt;. DCP runs the project from whatever is already in its output folder; it doesn't compile first. So wherever &lt;code&gt;dotnet build&lt;/code&gt; put the worker DLL is exactly where DCP expects it. If the csproj sets &lt;code&gt;&amp;lt;RuntimeIdentifier&amp;gt;&lt;/code&gt; unconditionally (the "Honest scope" section walks through this), the DLL lands a directory deeper than DCP looks and the worker fails on launch. &lt;code&gt;func start&lt;/code&gt; papered over that; &lt;code&gt;dotnet run --no-build&lt;/code&gt; does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service discovery: two cases
&lt;/h2&gt;

&lt;p&gt;Host storage and user-defined connections behave differently. Host storage is auto-wired. User connections you wire yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 1: host storage.&lt;/strong&gt; &lt;code&gt;AddAzureFunctionsProject&amp;lt;T&amp;gt;(...)&lt;/code&gt; injects the env var &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; into the worker process, set to the full Azurite connection string for local runs (the literal account-key form, not &lt;code&gt;UseDevelopmentStorage=true&lt;/code&gt;). The worker reads that env var directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessOrder&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;QueueTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"AzureWebJobsStorage"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// worker code unchanged from the non-Aspire setup&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trigger attribute didn't change. The &lt;code&gt;Connection = "AzureWebJobsStorage"&lt;/code&gt; string still names an env var; the AppHost is just the thing setting it now. Trigger attributes that omit &lt;code&gt;Connection&lt;/code&gt; entirely also fall back to &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; and resolve the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 2: user-defined resources.&lt;/strong&gt; A queue that belongs to your application (not to the host) gets a name you choose, and the trigger has to match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ordersStorage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAzureStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-storage"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RunAsEmulator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ordersQueue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ordersStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddQueues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"queues"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_Queue&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-queue"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ordersQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"OrdersConnection"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second argument to &lt;code&gt;WithReference&lt;/code&gt; is the literal env var the Functions runtime resolves. The trigger then names that same string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessOrder&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;QueueTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"OrdersConnection"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contract between AppHost and worker is exactly that string. No magic mapping happens; a resource called &lt;code&gt;orders-storage&lt;/code&gt; is not auto-injected as &lt;code&gt;OrdersConnection&lt;/code&gt;. If the names don't match, the Functions runtime can't find the connection and the trigger fails to start.&lt;/p&gt;

&lt;p&gt;Auto-wiring (where the second argument to &lt;code&gt;WithReference&lt;/code&gt; is the only thing you need) covers four integrations: Azure Blob Storage, Azure Queue Storage, Azure Event Hubs, Azure Service Bus. For other resources, you wire one env var manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cache"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_Http&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-http"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"RedisConnection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectionStringExpression&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One extra line per resource. Not free, but localised.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the worker actually receives
&lt;/h2&gt;

&lt;p&gt;The claim that the AppHost is "just the thing setting the env var now" is checkable. Open the dashboard, select the &lt;code&gt;orders-queue&lt;/code&gt; row, and look at its environment variables (the Resources page lists them per resource). For the shared-host-storage setup above, &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; is set to the full Azurite connection string, account key and all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AzureWebJobsStorage=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8.../...;BlobEndpoint=http://127.0.0.1:&amp;lt;port&amp;gt;/devstoreaccount1;QueueEndpoint=http://127.0.0.1:&amp;lt;port&amp;gt;/devstoreaccount1;TableEndpoint=http://127.0.0.1:&amp;lt;port&amp;gt;/devstoreaccount1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what it is not: &lt;code&gt;UseDevelopmentStorage=true&lt;/code&gt;. Aspire resolves the running Azurite container's mapped ports and writes the literal account-key form. That is the clearest single sign the connection string no longer lives in a file you maintain; the worker reads a value the AppHost computed at startup from a container it owns.&lt;/p&gt;

&lt;p&gt;A second injected variable is the one that makes host logs show up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AzureFunctionsJobHost__telemetryMode=OpenTelemetry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the configuration equivalent of setting &lt;code&gt;"telemetryMode": "OpenTelemetry"&lt;/code&gt; in &lt;code&gt;host.json&lt;/code&gt;, and Aspire sets it on the worker for you. It's why the Functions host's own logs reach the dashboard with no &lt;code&gt;host.json&lt;/code&gt; edit.&lt;/p&gt;

&lt;p&gt;Aspire also writes a set of hierarchical keys for its typed storage clients (&lt;code&gt;Aspire__Azure__Storage__Queues__AzureWebJobsStorage__ConnectionString&lt;/code&gt; and siblings). The default Functions binding extensions ignore them; they matter only if the Functions project adds the matching &lt;code&gt;Aspire.Azure.Storage.*&lt;/code&gt; client package. You can leave them unread.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one change inside the Functions project
&lt;/h2&gt;

&lt;p&gt;Everything so far lived in the AppHost; the worker code was untouched. The dashboard's logs, traces, and metrics are the exception. They need one line in each Functions project's &lt;code&gt;Program.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddServiceDefaults&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureFunctionsWebApplication&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddServiceDefaults()&lt;/code&gt; comes from the &lt;code&gt;aspire-servicedefaults&lt;/code&gt; project, a small class library you reference from each Functions project. It registers the OpenTelemetry exporter that targets the dashboard, plus health checks and HttpClient resilience. The call has to land before &lt;code&gt;Build()&lt;/code&gt;, and the &lt;a href="https://learn.microsoft.com/azure/azure-functions/dotnet-aspire-integration" rel="noopener noreferrer"&gt;integration doc&lt;/a&gt; is specific that it goes on the &lt;code&gt;IHostApplicationBuilder&lt;/code&gt; that &lt;code&gt;FunctionsApplication.CreateBuilder&lt;/code&gt; returns, not the older &lt;code&gt;HostBuilder().ConfigureFunctionsWorkerDefaults()&lt;/code&gt; style.&lt;/p&gt;

&lt;p&gt;It's a no-op when you run the project standalone with &lt;code&gt;func start&lt;/code&gt;. The OTLP exporter only activates when &lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/code&gt; is set, which the AppHost does and a bare &lt;code&gt;func start&lt;/code&gt; does not. So the same code runs both ways: under Aspire it lights up the dashboard, outside it stays quiet.&lt;/p&gt;

&lt;p&gt;This is also where the App Insights cleanup belongs. If &lt;code&gt;Program.cs&lt;/code&gt; still calls &lt;code&gt;AddApplicationInsightsTelemetryWorkerService()&lt;/code&gt;, drop it (the "Honest scope" section explains why) and let &lt;code&gt;AddServiceDefaults&lt;/code&gt; own telemetry.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dashboard
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dotnet run --project AspireDemo.AppHost&lt;/code&gt; boots everything and prints a line that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Login to the dashboard at https://localhost:17281/login?t=&amp;lt;token&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The port is randomised by &lt;code&gt;Properties/launchSettings.json&lt;/code&gt; (pin it there if you want a stable URL); the token is regenerated every run and persists as a browser cookie for three days. From Visual Studio or VS Code with the Aspire extension the browser opens automatically; from the CLI you ctrl-click the URL.&lt;/p&gt;

&lt;p&gt;The dashboard has four pages worth knowing about for a Functions app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resources.&lt;/strong&gt; Every resource you declared (your Functions projects, the host storage emulator, any queues or Service Bus namespaces) shows up as a row. Each row has a state (Running, Starting, Failed), endpoints, and a per-resource log tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs.&lt;/strong&gt; Structured logs from the worker flow to the dashboard because of the &lt;code&gt;AddServiceDefaults()&lt;/code&gt; call from the previous section, combined with the auto-injected &lt;code&gt;AzureFunctionsJobHost__telemetryMode=OpenTelemetry&lt;/code&gt;. No &lt;code&gt;host.json&lt;/code&gt; edits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traces.&lt;/strong&gt; ASP.NET Core, HttpClient, and the Azure SDK &lt;code&gt;Azure.*&lt;/code&gt; ActivitySources participate by default, so calls that cross those boundaries link up into one span tree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics.&lt;/strong&gt; &lt;code&gt;Microsoft.AspNetCore.*&lt;/code&gt;, &lt;code&gt;System.Net.Http&lt;/code&gt;, and the .NET runtime meters are wired in by &lt;code&gt;AddServiceDefaults&lt;/code&gt;. Per-invocation Functions metrics under a &lt;code&gt;Microsoft.Azure.Functions.*&lt;/code&gt; meter still require explicit wiring in the worker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tracing is the page that pays off the multi-project setup. The sample gives &lt;code&gt;CreateOrderFunction&lt;/code&gt; in &lt;code&gt;orders-http&lt;/code&gt; a &lt;code&gt;[QueueOutput("orders")]&lt;/code&gt; binding and &lt;code&gt;ProcessOrderFunction&lt;/code&gt; in &lt;code&gt;orders-queue&lt;/code&gt; the matching &lt;code&gt;[QueueTrigger("orders")]&lt;/code&gt;. A single &lt;code&gt;POST /api/orders&lt;/code&gt; then produces one connected trace that spans both apps: the inbound HTTP server span in &lt;code&gt;orders-http&lt;/code&gt;, the Azure Storage Queue send span beneath it, and the queue-trigger span in &lt;code&gt;orders-queue&lt;/code&gt; that fires when the message is dequeued. Two processes, one span tree, because the Azure SDK propagates W3C trace context through the queue message. That cross-process link is the thing three separate &lt;code&gt;func start&lt;/code&gt; terminals could never show you.&lt;/p&gt;

&lt;p&gt;The dashboard isn't a replacement for production &lt;a href="https://learn.microsoft.com/azure/azure-monitor/app/app-insights-overview" rel="noopener noreferrer"&gt;Application Insights&lt;/a&gt;. It's the same OTLP data your Functions app would send to App Insights in production, except routed to a local UI you see in the first ten seconds of &lt;code&gt;dotnet run&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after
&lt;/h2&gt;

&lt;p&gt;The migration from the original sample to the AppHost is almost entirely additive. You add two projects (the AppHost and the small service-defaults library), share the host storage, and trim the &lt;code&gt;local.settings.json&lt;/code&gt; files. The only edit inside the existing Functions projects is the single &lt;code&gt;AddServiceDefaults()&lt;/code&gt; line from earlier, plus a reference to the service-defaults project. The DX shift:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdfv7mo6vd5ptwqo52rsq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdfv7mo6vd5ptwqo52rsq.png" alt="Before and after: the developer-experience shift from per-machine local.settings.json to a shared Aspire AppHost" width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The "untracked config files" row is the one most teams underestimate. The file doesn't disappear; it shrinks to a single setting (&lt;code&gt;FUNCTIONS_WORKER_RUNTIME&lt;/code&gt;). Removing it entirely is a future enhancement on the Functions team's roadmap; today's GA story has you keep it minimal but not absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest scope
&lt;/h2&gt;

&lt;p&gt;A few caveats that don't fit the marketing slide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trigger auto-wiring is four integrations.&lt;/strong&gt; Blob, Queue, Event Hubs, Service Bus. Anything else (Cosmos DB, Redis, SignalR, SQL, custom HTTP services) needs the &lt;code&gt;WithEnvironment("Name", resource.ConnectionStringExpression)&lt;/code&gt; form. One extra line per resource. Real, but bounded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;local.settings.json&lt;/code&gt; must lose the &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; line.&lt;/strong&gt; Leaving &lt;code&gt;"AzureWebJobsStorage": "UseDevelopmentStorage=true"&lt;/code&gt; in the file (the template default) makes the Functions host try to spin up against Azurite directly, while Aspire is also running its own Azurite container. You end up with two emulators and a port conflict. Trim the file to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"IsEncrypted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Values"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"FUNCTIONS_WORKER_RUNTIME"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dotnet-isolated"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Remove direct App Insights wiring from Functions.&lt;/strong&gt; If your &lt;code&gt;Program.cs&lt;/code&gt; calls &lt;code&gt;AddApplicationInsightsTelemetryWorkerService()&lt;/code&gt;, drop it and rely on &lt;code&gt;AddServiceDefaults&lt;/code&gt; (which configures the OTLP exporter the dashboard reads). &lt;code&gt;Microsoft.ApplicationInsights.WorkerService 2.22.0&lt;/code&gt; had a runtime conflict against Aspire that was fixed in 2.23.0; if you can't upgrade, the safer move is to remove the App Insights worker package and route everything through OpenTelemetry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unconditional &lt;code&gt;&amp;lt;RuntimeIdentifier&amp;gt;&lt;/code&gt; breaks &lt;code&gt;dotnet run --no-build&lt;/code&gt;.&lt;/strong&gt; If your Functions csproj has &lt;code&gt;&amp;lt;RuntimeIdentifier&amp;gt;linux-x64&amp;lt;/RuntimeIdentifier&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;PublishReadyToRun&amp;gt;true&amp;lt;/PublishReadyToRun&amp;gt;&lt;/code&gt; set unconditionally (a common copy-paste from a CI sample), &lt;code&gt;dotnet build&lt;/code&gt; puts the worker DLL under &lt;code&gt;bin/Debug/net10.0/linux-x64/&lt;/code&gt; instead of &lt;code&gt;bin/Debug/net10.0/&lt;/code&gt;. As the "How Aspire starts the worker" section explained, DCP loads from that path without rebuilding, so on macOS arm64 the worker crashes immediately. Scope both properties to Release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;PropertyGroup&lt;/span&gt; &lt;span class="na"&gt;Condition=&lt;/span&gt;&lt;span class="s"&gt;"'$(Configuration)' == 'Release'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;RuntimeIdentifier&amp;gt;&lt;/span&gt;linux-x64&lt;span class="nt"&gt;&amp;lt;/RuntimeIdentifier&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;PublishReadyToRun&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/PublishReadyToRun&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Publishing as a real &lt;code&gt;functionapp,linux&lt;/code&gt; App Service is preview.&lt;/strong&gt; The default Aspire publish target is Azure Container Apps, which is GA. If your team requires App Service plans (Consumption, Premium, Dedicated), &lt;code&gt;Aspire.Hosting.Azure.AppService&lt;/code&gt; is the package and it's still &lt;code&gt;13.x-preview&lt;/code&gt; as of May 2026. Part 3 of this series covers publish targets and the trade-offs.&lt;/p&gt;

&lt;p&gt;The local-dev story this article sells (AppHost, dashboard, Azurite + Service Bus emulators, structured logs, traces, the host-storage and user-trigger patterns) is GA in Aspire 13.1+. Nothing in the sections above is preview.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still copying local.settings.json.example, or moved to an AppHost?
&lt;/h2&gt;

&lt;p&gt;If you've migrated, what's the friction point in the workflow you didn't expect? If you haven't, what's the blocker: the one extra &lt;code&gt;WithEnvironment&lt;/code&gt; line per non-auto-wired resource, the publish path you'd need (Container Apps versus App Service), or the trim-not-delete shape of &lt;code&gt;local.settings.json&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;The companion sample for this article lives at &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/AspireDemo" rel="noopener noreferrer"&gt;&lt;code&gt;AspireDemo/&lt;/code&gt; in azure-functions-samples&lt;/a&gt;. It reuses the existing &lt;code&gt;ProjectOrganizationDemo&lt;/code&gt; projects, adds the AppHost and a service-defaults library above them, and trims both &lt;code&gt;local.settings.json&lt;/code&gt; files. The migration is nearly additive: the two Functions projects are project references from the AppHost, and the only worker-code change is one &lt;code&gt;AddServiceDefaults()&lt;/code&gt; line each, the line that routes their telemetry to the dashboard.&lt;/p&gt;

&lt;p&gt;Part 2 of the series takes the same AppHost and adds Service Bus, additional storage, and a Redis cache as Aspire resources. Part 3 walks through the publish path with &lt;code&gt;azd&lt;/code&gt; and Container Apps, and shows what Aspire generates under the hood.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>aspire</category>
      <category>dotnet</category>
    </item>
  </channel>
</rss>
