<?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>Infrastructure as Code: Terraform for Your New Stack</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 25 Sep 2026 06:46:19 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/infrastructure-as-code-terraform-for-your-new-stack-49m3</link>
      <guid>https://dev.to/martin_oehlert/infrastructure-as-code-terraform-for-your-new-stack-49m3</guid>
      <description>&lt;p&gt;The first &lt;code&gt;terraform apply&lt;/code&gt; against a fresh subscription created the Container Apps environment, created the app, and then the revision failed to pull its image. The second apply, with nothing changed, succeeded. The &lt;code&gt;AcrPull&lt;/code&gt; role assignment existed both times; what was missing on the first run was an edge in Terraform's dependency graph, because the container app references the identity and the registry login server and has no reason to reference the grant. Terraform orders what you reference, Azure requires things you would never reference, and that gap is where most of the production failures in a Container Apps stack live.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/martin_oehlert/azure-front-door-api-management-and-container-apps-what-each-hop-trusts-38ik"&gt;Part 6&lt;/a&gt; described that architecture as a chain of hops and asked what each one trusts. Written down as Terraform it is fifteen resources in the root module before a single &lt;code&gt;module&lt;/code&gt; block runs, and four modules under it holding the VNet, the private endpoints, the environment, the apps, the Dapr components, the identities and their grants.&lt;/p&gt;

&lt;p&gt;Every HCL block below is lifted out of a tree that &lt;code&gt;terraform validate&lt;/code&gt;s on &lt;strong&gt;Terraform 1.16.3&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;hashicorp/azurerm&lt;/code&gt; 5.6.0&lt;/strong&gt;, published 2026-09-17, and the whole tree is in the &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/TerraformContainerAppsDemo" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;. The pair is pinned in one place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.16.0"&lt;/span&gt;

  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;azurerm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/azurerm"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"5.6.0"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Partial configuration. The rest comes from envs/&amp;lt;env&amp;gt;.backend.hcl at init time:&lt;/span&gt;
  &lt;span class="c1"&gt;#   terraform init -reconfigure -backend-config=envs/prod.backend.hcl&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"azurerm"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"azurerm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;features&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nx"&gt;subscription_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscription_id&lt;/span&gt;

  &lt;span class="c1"&gt;# v5 defaults this to "none". On a fresh subscription the first apply then fails on an&lt;/span&gt;
  &lt;span class="c1"&gt;# unregistered Microsoft.App, and the error reads like a permissions problem.&lt;/span&gt;
  &lt;span class="nx"&gt;resource_provider_registrations&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"legacy"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing about that validation matters before you trust your own: &lt;code&gt;terraform init&lt;/code&gt; without &lt;code&gt;-upgrade&lt;/code&gt; happily resolved an azurerm 4.x build out of the local plugin cache with a 5.x constraint sitting in &lt;code&gt;required_providers&lt;/code&gt;, and the three v4-shaped errors that followed read as though the configuration was wrong. &lt;code&gt;terraform version -json&lt;/code&gt; prints &lt;code&gt;provider_selections&lt;/code&gt;; check it before you start editing code that was fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four modules, drawn where the lifetime changes
&lt;/h2&gt;

&lt;p&gt;A module boundary is a &lt;strong&gt;lifetime&lt;/strong&gt; boundary, not a service boundary. Here is the root module wiring the four of them together, with the arguments that only carry data trimmed out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./modules/networking"&lt;/span&gt;

  &lt;span class="nx"&gt;address_space&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address_space&lt;/span&gt;

  &lt;span class="c1"&gt;# 7 newbits on a /16 gives a /23. /27 is the legal minimum for workload profiles, but the&lt;/span&gt;
  &lt;span class="c1"&gt;# subnet cannot be resized afterwards and a /27 caps at 9 Dedicated nodes.&lt;/span&gt;
  &lt;span class="nx"&gt;aca_subnet_newbits&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;required_tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"container_apps"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./modules/container-apps"&lt;/span&gt;

  &lt;span class="nx"&gt;log_analytics_workspace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_log_analytics_workspace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;infrastructure_subnet_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_apps_subnet_id&lt;/span&gt;
  &lt;span class="nx"&gt;internal_only&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nx"&gt;container_registry_login_server&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_container_registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;login_server&lt;/span&gt;

  &lt;span class="nx"&gt;app_identities&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="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders_app_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity_ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders_app_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="nx"&gt;client_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client_ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders_app_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;required_tags&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"dapr_components"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./modules/dapr-components"&lt;/span&gt;

  &lt;span class="nx"&gt;container_app_environment_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_apps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment_id&lt;/span&gt;
  &lt;span class="nx"&gt;dapr_identity_client_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client_ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders_app_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;state_store_scopes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_apps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dapr_app_ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders_app_id&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
  &lt;span class="nx"&gt;pubsub_scopes&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_apps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dapr_app_ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders_app_id&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 VNet outlives the architecture: you will still be running that address space after the apps inside it have been rewritten twice. The environment is near-permanent for reasons the next section is entirely about. The apps change every time somebody merges. Cut along those rates and each module has one reason to be applied. Cut by Azure service instead and you get a &lt;code&gt;cosmos&lt;/code&gt; module that has to be planned every time an image tag moves.&lt;/p&gt;

&lt;p&gt;What crosses each boundary is an &lt;code&gt;output&lt;/code&gt; feeding a &lt;code&gt;var&lt;/code&gt;, and that reference is the only thing Terraform needs to order the two. &lt;code&gt;module.networking&lt;/code&gt; exports the delegated subnet ID; &lt;code&gt;module.container_apps&lt;/code&gt; exports the environment ID, its default domain, its static IP, the app FQDNs and the Dapr app ids; &lt;code&gt;module.identity&lt;/code&gt; exports identity IDs, client IDs and principal IDs as three separate maps, because they are not interchangeable and a swap fails at runtime with nothing visible at plan time. &lt;code&gt;module.dapr_components&lt;/code&gt; exports nothing anyone consumes, which is a fine thing for a leaf module to do.&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%2Fqo519ls040dq43ex9jqm.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%2Fqo519ls040dq43ex9jqm.png" alt="Module dependency graph: solid edges are outputs wired into the next module's variables, dashed edges are explicit depends_on for the AcrPull grant and the NSG association" width="712" height="818"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two dashed edges are the ones Terraform cannot draw, and the &lt;code&gt;depends_on&lt;/code&gt; on &lt;code&gt;module.container_apps&lt;/code&gt; is where you write them down by hand. &lt;code&gt;module.identity&lt;/code&gt; is there for the failed image pull in the first paragraph of this article. &lt;code&gt;module.networking&lt;/code&gt; is there because the environment references the subnet and never the NSG association, so without the edge the environment can come up against an unprotected subnet. The sample puts both on the module call rather than on the resource, since the identities and the apps live in different modules; inside a single module the same fix is &lt;code&gt;depends_on = [azurerm_role_assignment.acr_pull]&lt;/code&gt; on the &lt;code&gt;azurerm_container_app&lt;/code&gt; resource itself. The identity chain gets taken apart properly further down.&lt;/p&gt;

&lt;p&gt;Three patterns would fail a module review at a stricter shop, and they are worth naming because most Terraform repos contain all three. A module wrapping a single resource with a rename is a rename. A resource you configure once and never vary does not need a variable, let alone a module. And a passthrough module, where every variable maps one-to-one onto a provider argument, has added a file and taken away the provider documentation. &lt;code&gt;dapr-components&lt;/code&gt; survives that test by a narrow margin: it hard-codes the component types and versions, it refuses an empty &lt;code&gt;scopes&lt;/code&gt; list, and it hides the &lt;code&gt;azureClientId&lt;/code&gt; plumbing. A generic "any Dapr component" module would do none of those things and would be worse than writing the resource directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The environment: every interesting argument is a one-way door
&lt;/h2&gt;

&lt;p&gt;The resource that decides the entire network posture of this stack has no required network argument at all. Omit &lt;code&gt;infrastructure_subnet_id&lt;/code&gt; and you get a public environment outside your VNet, and it plans clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;missing_identities&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;setsubtract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apps&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_identities&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_container_app_environment"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cae-${var.workload}-${var.environment}"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;

  &lt;span class="nx"&gt;logs_destination&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"log-analytics"&lt;/span&gt;
  &lt;span class="nx"&gt;log_analytics_workspace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log_analytics_workspace_id&lt;/span&gt;

  &lt;span class="nx"&gt;infrastructure_subnet_id&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infrastructure_subnet_id&lt;/span&gt;
  &lt;span class="nx"&gt;infrastructure_resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rg-${var.workload}-${var.environment}-aca-infra"&lt;/span&gt;
  &lt;span class="nx"&gt;internal_load_balancer_enabled&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;internal_only&lt;/span&gt;
  &lt;span class="nx"&gt;zone_redundancy_enabled&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt;

  &lt;span class="nx"&gt;dapr_application_insights_connection_string&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dapr_application_insights_connection_string&lt;/span&gt;

  &lt;span class="nx"&gt;workload_profile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Consumption"&lt;/span&gt;
    &lt;span class="nx"&gt;workload_profile_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Consumption"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="s2"&gt;"workload_profile"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dedicated_workload_profiles&lt;/span&gt;

    &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;workload_profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
      &lt;span class="nx"&gt;workload_profile_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;workload_profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;profile_type&lt;/span&gt;
      &lt;span class="nx"&gt;minimum_count&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;workload_profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minimum_count&lt;/span&gt;
      &lt;span class="nx"&gt;maximum_count&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;workload_profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maximum_count&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;

  &lt;span class="nx"&gt;lifecycle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;precondition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;missing_identities&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
      &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Every app needs an identity: ${join("&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;", local.missing_identities)} are in apps but not in app_identities."&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;Nearly every argument above forces replacement when it changes: &lt;code&gt;infrastructure_subnet_id&lt;/code&gt;, &lt;code&gt;infrastructure_resource_group_name&lt;/code&gt;, &lt;code&gt;internal_load_balancer_enabled&lt;/code&gt;, &lt;code&gt;zone_redundancy_enabled&lt;/code&gt;, &lt;code&gt;dapr_application_insights_connection_string&lt;/code&gt;, the &lt;code&gt;workload_profile&lt;/code&gt; blocks, and the size of the subnet the first one points at. Replacing a Container Apps environment takes every container app and every Dapr component inside it along, so those are decisions you make once, on day one, with the same weight you would give a database engine choice.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;workload_profile&lt;/code&gt; is the trap, because nothing about it looks final. It is an optional repeatable block with optional arguments, the shape Terraform uses everywhere for things you tune later. The &lt;a href="https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_app_environment" rel="noopener noreferrer"&gt;provider note&lt;/a&gt; is explicit: "Environments created without an initial Workload Profile cannot have them added at a later time and must be recreated. Similarly, an environment created with Profiles must always have at least one defined Profile, removing all profiles will force a recreation of the resource." That is why the static &lt;code&gt;Consumption&lt;/code&gt; block sits above the &lt;code&gt;dynamic&lt;/code&gt; one rather than inside it. A &lt;code&gt;Consumption&lt;/code&gt; profile must be named &lt;code&gt;Consumption&lt;/code&gt;, there can only be one, and keeping it out of the map means an operator who empties &lt;code&gt;dedicated_workload_profiles&lt;/code&gt; removes a dedicated profile instead of destroying the environment.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;logs_destination = "log-analytics"&lt;/code&gt; is there because v5 stopped computing it. A v4 configuration that set only &lt;code&gt;log_analytics_workspace_id&lt;/code&gt; and got log analytics by default now drops to Streaming Only on upgrade, with no error, and you find out when you go looking for a query result that is not there.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;dedicated_workload_profiles&lt;/code&gt; variable is where the environment's rules get written down, and the third validation on it is a different kind of rule from the first two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"dedicated_workload_profiles"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;profile_type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;minimum_count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;number&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="nx"&gt;maximum_count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nx"&gt;nullable&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Dedicated profiles keyed by profile name. A Consumption profile is always added, because removing the last profile recreates the environment."&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alltrue&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dedicated_workload_profiles&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"^(D4|D8|D16|D32|E4|E8|E16|E32)$"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;profile_type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"profile_type must be one of D4, D8, D16, D32, E4, E8, E16, E32."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alltrue&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dedicated_workload_profiles&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maximum_count&lt;/span&gt; &lt;span class="err"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minimum_count&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"maximum_count must be greater than or equal to minimum_count."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dedicated_workload_profiles&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;"Consumption"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The Consumption profile is added by the module; do not declare it here."&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 first two encode Azure rules, and Azure would have rejected the apply anyway; all the validation buys you is a readable error twenty minutes earlier. The third encodes a rule Azure has never heard of. It exists because this module chose to add the &lt;code&gt;Consumption&lt;/code&gt; profile itself, and a caller who declares one would get a duplicate name from a design decision they had no way to see. Validation blocks are most useful for exactly that: the constraints your module invented, which no provider schema and no API will ever check for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The app: one variable is the whole interface
&lt;/h2&gt;

&lt;p&gt;Callers of the container-apps module never touch a resource. They fill in one map, and the key of that map is the &lt;strong&gt;Dapr app id&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"apps"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;image&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;target_port&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;number&lt;/span&gt;
    &lt;span class="nx"&gt;cpu&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;memory&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"1Gi"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;min_replicas&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;number&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="nx"&gt;max_replicas&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;workload_profile_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Consumption"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;key_vault_secrets&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;

    &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;external_enabled&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;allowed_ip_ranges&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;string&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="nx"&gt;service_bus_scale_rule&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;queue_name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
      &lt;span class="nx"&gt;namespace&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
      &lt;span class="nx"&gt;message_count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&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="nx"&gt;nullable&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Apps keyed by Dapr app id. The map key becomes the dapr app_id and the container app name suffix, so a Dapr component can never be scoped to a string that does not exist."&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alltrue&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apps&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"^[a-z][a-z0-9-]{1,30}[a-z0-9]$"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))])&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"App keys become Dapr app ids and container app names; use lower-case alphanumeric and hyphens, 3-32 chars."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alltrue&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apps&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;min_replicas&lt;/span&gt; &lt;span class="err"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max_replicas&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"min_replicas must be less than or equal to max_replicas."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alltrue&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apps&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cpu&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cpu must be one of the allocations the Consumption profile accepts: 0.25 through 2.0 in 0.25 steps."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# KEDA's namespace metadata is the bare name. Dapr's namespaceName, two modules over, is the&lt;/span&gt;
  &lt;span class="c1"&gt;# FQDN. Same concept, two formats, and neither one fails at plan time.&lt;/span&gt;
  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alltrue&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apps&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service_bus_scale_rule&lt;/span&gt; &lt;span class="err"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="err"&gt;||&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;strcontains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;try&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service_bus_scale_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The Service Bus scale rule namespace is the bare namespace name, not the FQDN. Drop the .servicebus.windows.net suffix."&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;Keying on the Dapr app id rather than on the container app name is the one design decision in this module that changes what can go wrong later. A Dapr component's &lt;code&gt;scopes&lt;/code&gt; list matches on app ids, the app id is otherwise just a string somebody typed twice, and the next section is about what happens when the two copies drift. Making it the map key means it is typed once, in tfvars, and everything else is derived.&lt;/p&gt;

&lt;p&gt;Of the four validations, the character-set rule is the one that pays for itself. Azure enforces a name format on the container app and Dapr enforces its own on the app id, and the map key has to satisfy both, so checking it at the interface beats reading two different rejections from two different services. The CPU one covers an allocation set the provider does not check at all. The last one is the &lt;code&gt;namespaceName&lt;/code&gt; versus &lt;code&gt;namespace&lt;/code&gt; split: Dapr's Service Bus component wants the fully qualified namespace and KEDA's scaler wants the bare name, the two live two modules apart in the same stack, and neither one fails at plan time if you supply the other format.&lt;/p&gt;

&lt;p&gt;Cross-variable validation is why the module needs Terraform 1.9 as a floor. The networking module's subnet check reads &lt;code&gt;tonumber(split("/", var.address_space)[1]) + var.aca_subnet_newbits &amp;lt;= 27&lt;/code&gt;, and a &lt;code&gt;validation&lt;/code&gt; block that references a second variable was an error before 1.9.&lt;/p&gt;

&lt;p&gt;Then the resource, which is mostly &lt;code&gt;each.value&lt;/code&gt; plumbing plus four things worth stopping on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_container_app"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apps&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;                         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ca-${each.key}-${var.environment}"&lt;/span&gt;
  &lt;span class="nx"&gt;container_app_environment_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_container_app_environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;revision_mode&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Single"&lt;/span&gt;
  &lt;span class="nx"&gt;workload_profile_name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workload_profile_name&lt;/span&gt;
  &lt;span class="nx"&gt;max_inactive_revisions&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;                         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;

  &lt;span class="nx"&gt;identity&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"UserAssigned"&lt;/span&gt;
    &lt;span class="nx"&gt;identity_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_identities&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;registry&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;server&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_registry_login_server&lt;/span&gt;
    &lt;span class="nx"&gt;identity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_identities&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# A Key Vault reference, not a copy of the value into state. The identity here has to be the&lt;/span&gt;
  &lt;span class="c1"&gt;# same one the AcrPull grant went to, or the revision fails to start on secret resolution.&lt;/span&gt;
  &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="s2"&gt;"secret"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key_vault_secrets&lt;/span&gt;

    &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
      &lt;span class="nx"&gt;identity&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_identities&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
      &lt;span class="nx"&gt;key_vault_secret_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# app_id is the string Dapr component scopes match on. It is deliberately the map key, so the&lt;/span&gt;
  &lt;span class="c1"&gt;# dapr-components module can read it back off this resource instead of guessing a convention.&lt;/span&gt;
  &lt;span class="nx"&gt;dapr&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;app_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
    &lt;span class="nx"&gt;app_port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target_port&lt;/span&gt;
    &lt;span class="nx"&gt;app_protocol&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="s2"&gt;"ingress"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ingress&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;external_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ingress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;external_enabled&lt;/span&gt;
      &lt;span class="nx"&gt;target_port&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target_port&lt;/span&gt;
      &lt;span class="nx"&gt;transport&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"auto"&lt;/span&gt;

      &lt;span class="nx"&gt;traffic_weight&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;latest_revision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="nx"&gt;percentage&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&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="nx"&gt;template&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;min_replicas&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;min_replicas&lt;/span&gt;
    &lt;span class="nx"&gt;max_replicas&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max_replicas&lt;/span&gt;
    &lt;span class="nx"&gt;polling_interval_in_seconds&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
    &lt;span class="nx"&gt;cooldown_period_in_seconds&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
    &lt;span class="nx"&gt;termination_grace_period_seconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;

    &lt;span class="nx"&gt;container&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
      &lt;span class="nx"&gt;image&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;
      &lt;span class="nx"&gt;cpu&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cpu&lt;/span&gt;
      &lt;span class="nx"&gt;memory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memory&lt;/span&gt;

      &lt;span class="c1"&gt;# The client ID, not the principal ID. Both are GUIDs on the same identity and swapping&lt;/span&gt;
      &lt;span class="c1"&gt;# them fails at runtime with nothing to see at plan time.&lt;/span&gt;
      &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AZURE_CLIENT_ID"&lt;/span&gt;
        &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_identities&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;client_id&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nx"&gt;readiness_probe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;transport&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTP"&lt;/span&gt;
        &lt;span class="nx"&gt;port&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target_port&lt;/span&gt;
        &lt;span class="nx"&gt;path&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/healthz/ready"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="s2"&gt;"http_scale_rule"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="err"&gt;:&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="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http-rps"&lt;/span&gt;
        &lt;span class="nx"&gt;concurrent_requests&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"50"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# identity_id on custom_scale_rule landed in azurerm 4.69.0. Below that floor a Service Bus&lt;/span&gt;
    &lt;span class="c1"&gt;# scale rule needs a connection string secret instead of the workload identity.&lt;/span&gt;
    &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="s2"&gt;"custom_scale_rule"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service_bus_scale_rule&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service_bus_scale_rule&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

      &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sb-queue-depth"&lt;/span&gt;
        &lt;span class="nx"&gt;custom_rule_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"azure-servicebus"&lt;/span&gt;
        &lt;span class="nx"&gt;identity_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_identities&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

        &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;queueName&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;custom_scale_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue_name&lt;/span&gt;
          &lt;span class="nx"&gt;namespace&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;custom_scale_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;namespace&lt;/span&gt;
          &lt;span class="nx"&gt;messageCount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tostring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;custom_scale_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message_count&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;span class="nx"&gt;lifecycle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;precondition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="err"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service_bus_scale_rule&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
      &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"App ${each.key} has neither ingress nor a queue scale rule, so nothing can ever wake it."&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;precondition&lt;/code&gt; catches what no &lt;code&gt;validation&lt;/code&gt; block can reach, because it depends on two optional attributes of the same object rather than on one variable. An app with no &lt;code&gt;ingress&lt;/code&gt; and no scale rule is legal HCL, legal Azure, and applies green. It then sits at zero replicas forever, because nothing can wake it: no ingress means no HTTP traffic, and no queue rule means no backlog signal. The failure is silence, which is the worst shape a failure can take in infrastructure code.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AZURE_CLIENT_ID&lt;/code&gt; carries the &lt;strong&gt;client ID&lt;/strong&gt;. The role assignments that give this identity its Cosmos, Service Bus and Key Vault access take the &lt;strong&gt;principal ID&lt;/strong&gt;. Both are GUIDs hanging off the same &lt;code&gt;azurerm_user_assigned_identity&lt;/code&gt;, they look identical in a plan diff, and swapping them produces a clean apply and a 401 at runtime. That is the reason the identity module exports &lt;code&gt;client_ids&lt;/code&gt; and &lt;code&gt;principal_ids&lt;/code&gt; as two separately named maps instead of one convenient map of identity objects.&lt;/p&gt;

&lt;p&gt;Three more things in that resource only start mattering after the first apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revisions.&lt;/strong&gt; &lt;code&gt;revision_mode = "Single"&lt;/code&gt; here pairs with a &lt;code&gt;traffic_weight&lt;/code&gt; block that just says &lt;code&gt;latest_revision = true&lt;/code&gt; at 100 percent. Switch to &lt;code&gt;Multiple&lt;/code&gt; and that block becomes a list you own: percentages have to sum to exactly 100, nothing is defaulted for you, and &lt;code&gt;traffic_weight&lt;/code&gt; has no meaning at all in &lt;code&gt;Single&lt;/code&gt; mode. &lt;code&gt;max_inactive_revisions = 3&lt;/code&gt; is the knob almost nobody sets. Container Apps keeps inactive revisions around, they show up in every listing and every diff, and three is enough history to roll back to while staying readable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scaling.&lt;/strong&gt; What &lt;code&gt;concurrent_requests = "50"&lt;/code&gt; actually counts is the thing &lt;a href="https://dev.to/martin_oehlert/azure-front-door-api-management-and-container-apps-what-each-hop-trusts-38ik#the-http-rule-counts-a-rate-not-concurrency"&gt;Part 6 settled with arithmetic&lt;/a&gt;, against a Microsoft page that contradicts itself two paragraphs apart; this article is about the packaging, so take the number from there.&lt;/p&gt;

&lt;p&gt;The packaging detail is &lt;code&gt;custom_scale_rule.identity_id&lt;/code&gt;, which landed in azurerm &lt;strong&gt;4.69.0&lt;/strong&gt;. Below that floor a Service Bus scale rule has to authenticate with a connection string, which means a shared access key in a secret, which means the one credential the rest of this architecture went out of its way not to have. Copy that argument name out of the registry page and you will also get &lt;code&gt;Error: Unsupported argument&lt;/code&gt;: the rendered docs spell it &lt;code&gt;ìdentity_id&lt;/code&gt;, with a Latin i-grave, and in most editors it looks exactly like &lt;code&gt;identity_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;polling_interval_in_seconds&lt;/code&gt; and &lt;code&gt;cooldown_period_in_seconds&lt;/code&gt; are &lt;code&gt;template&lt;/code&gt; arguments rather than scale-rule ones, and 4.51.0 itself had neither; &lt;code&gt;~&amp;gt; 4.51&lt;/code&gt; floats up to 4.81.0, which has both. They decide how quickly a scaled-to-zero worker notices a message and how long it lingers afterwards, so set them rather than inherit 30 and 300 by accident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets.&lt;/strong&gt; The &lt;code&gt;secret&lt;/code&gt; block takes either a literal &lt;code&gt;value&lt;/code&gt;, which puts the secret in state in cleartext, or a &lt;code&gt;key_vault_secret_id&lt;/code&gt;, which does not. The reference form needs an &lt;code&gt;identity&lt;/code&gt; alongside it, and it has to be an identity that actually holds a secrets-read grant on the vault, or the revision fails to start on secret resolution rather than on the image pull. The sample passes &lt;code&gt;versionless_id&lt;/code&gt; so that rotating the secret in Key Vault does not require a Terraform run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dapr components: &lt;code&gt;scopes&lt;/code&gt; is the security boundary, and both ways of getting it wrong are silent
&lt;/h2&gt;

&lt;p&gt;Get &lt;code&gt;scopes&lt;/code&gt; wrong in either direction and &lt;code&gt;terraform apply&lt;/code&gt; is green. Microsoft states the rule plainly: component scopes "correspond to the Dapr application ID of a container app, not the container app name", and "by default, all Dapr-enabled container apps in the same environment load the full set of deployed components" (&lt;a href="https://learn.microsoft.com/azure/container-apps/dapr-components#component-scopes" rel="noopener noreferrer"&gt;dapr-components&lt;/a&gt;). Those are the two failure shapes.&lt;/p&gt;

&lt;p&gt;Put the container app name in the list, or the Terraform resource name, or the app id with the environment suffix somebody added to the naming convention last quarter, and the component matches nothing. It is deployed, it is visible in the portal, it loads into no sidecar, and the first &lt;code&gt;POST /v1.0/state/statestore&lt;/code&gt; comes back 500 at runtime. Leave &lt;code&gt;scopes&lt;/code&gt; out entirely and the component loads into every Dapr-enabled app in the environment, including the ones with no business holding a connection to the orders database. The security argument for the second case is obvious; the cost argument is not. Dapr's Cosmos reference warns that Cosmos enforces a metadata request rate limit &lt;a href="https://docs.dapr.io/reference/components-reference/supported-state-stores/setup-azure-cosmosdb/" rel="noopener noreferrer"&gt;shared across the whole account&lt;/a&gt; and that new connections eat a large slice of it, then recommends scoping components to specific applications. Every sidecar in the environment opens its connection at startup whether it will ever read state or not.&lt;/p&gt;

&lt;p&gt;The fix inside one module is to make the scope an attribute reference, &lt;code&gt;scopes = [azurerm_container_app.orders.dapr[0].app_id]&lt;/code&gt;, so the two strings cannot drift. Across module boundaries the reference has to survive the trip, which is why the container-apps module reads its own resource back rather than reusing the input map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"dapr_app_ids"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;azurerm_container_app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dapr&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="nx"&gt;app_id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Dapr app ids read back off the container app resource. This is what the scopes list on a Dapr component expects; the container app name is not."&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;keys(var.apps)&lt;/code&gt; would have produced the same strings today and would have gone on producing them after someone added a prefix inside the module. Reading the attribute makes it an edge in the graph as well as a value.&lt;/p&gt;

&lt;p&gt;On the receiving side the components module refuses to accept the omission at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"state_store_scopes"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Dapr app ids allowed to load the state store. Not container app names, and not Terraform resource names."&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state_store_scopes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Set scopes explicitly; an unscoped component is loaded by every Dapr-enabled app in the environment."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no default. An unscoped component is a decision, and a module that lets you make it by forgetting to type something is a module that will make it for you.&lt;/p&gt;

&lt;p&gt;The three components themselves carry no credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# init_timeout is documented as an ISO 8601 string with the example "5s". Real ISO 8601 durations&lt;/span&gt;
&lt;span class="c1"&gt;# look like PT30S, and PT30S is not what the provider accepts. The Go-style form is correct.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_container_app_environment_dapr_component"&lt;/span&gt; &lt;span class="s2"&gt;"state_cosmos"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"statestore"&lt;/span&gt;
  &lt;span class="nx"&gt;container_app_environment_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_app_environment_id&lt;/span&gt;
  &lt;span class="nx"&gt;component_type&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"state.azure.cosmosdb"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"v1"&lt;/span&gt;
  &lt;span class="nx"&gt;init_timeout&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"30s"&lt;/span&gt;
  &lt;span class="nx"&gt;ignore_errors&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;scopes&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state_store_scopes&lt;/span&gt;

  &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"url"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cosmos_endpoint&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"database"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cosmos_database&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"collection"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cosmos_container&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"azureClientId"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dapr_identity_client_id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"actorStateStore"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# v2, not the v1 in the provider's own registry example. v1 strips the state key prefix as though&lt;/span&gt;
&lt;span class="c1"&gt;# keyPrefix were always none, and there is no migration path from v1 to v2.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_container_app_environment_dapr_component"&lt;/span&gt; &lt;span class="s2"&gt;"state_blob"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blob_state_store&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;                         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"checkpoints"&lt;/span&gt;
  &lt;span class="nx"&gt;container_app_environment_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_app_environment_id&lt;/span&gt;
  &lt;span class="nx"&gt;component_type&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"state.azure.blobstorage"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"v2"&lt;/span&gt;
  &lt;span class="nx"&gt;scopes&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state_store_scopes&lt;/span&gt;

  &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"azureClientId"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dapr_identity_client_id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_container_app_environment_dapr_component"&lt;/span&gt; &lt;span class="s2"&gt;"pubsub_servicebus"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"orders-pubsub"&lt;/span&gt;
  &lt;span class="nx"&gt;container_app_environment_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_app_environment_id&lt;/span&gt;
  &lt;span class="nx"&gt;component_type&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pubsub.azure.servicebus.topics"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"v1"&lt;/span&gt;
  &lt;span class="nx"&gt;scopes&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubsub_scopes&lt;/span&gt;

  &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"namespaceName"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service_bus_namespace_fqdn&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"azureClientId"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dapr_identity_client_id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"consumerID"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"{appID}"&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 &lt;code&gt;masterKey&lt;/code&gt;, no &lt;code&gt;connectionString&lt;/code&gt;, no &lt;code&gt;accountKey&lt;/code&gt;, and no &lt;code&gt;secret&lt;/code&gt; block backing any of them. The whole authentication story is &lt;code&gt;azureClientId&lt;/code&gt; plus the client ID of a user-assigned identity that holds data-plane role assignments, which is the point at which this stops being a Terraform question and becomes the role assignment question further down the page.&lt;/p&gt;

&lt;p&gt;Two lines in that sample disagree with the documentation on purpose. The blob component is &lt;code&gt;version = "v2"&lt;/code&gt; where the provider's own registry example writes &lt;code&gt;v1&lt;/code&gt;; Dapr's reference says users "should always use v2 by default" and that &lt;a href="https://docs.dapr.io/reference/components-reference/supported-state-stores/setup-azure-blobstorage/" rel="noopener noreferrer"&gt;there is no migration path from v1 to v2&lt;/a&gt;, because v1 strips the state key prefix as though &lt;code&gt;keyPrefix&lt;/code&gt; were always &lt;code&gt;none&lt;/code&gt;. Copy the registry example into a new module and you have inherited a legacy component you cannot upgrade in place. And &lt;code&gt;init_timeout&lt;/code&gt; is documented as "an &lt;code&gt;ISO8601&lt;/code&gt; formatted string. e.g. &lt;code&gt;5s&lt;/code&gt;, &lt;code&gt;2h&lt;/code&gt;, &lt;code&gt;1m&lt;/code&gt;", which is not what ISO 8601 durations look like. Write &lt;code&gt;PT30S&lt;/code&gt; and it fails. &lt;code&gt;30s&lt;/code&gt; is correct and the doc text is wrong about the name of the format it is describing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The subnet you cannot resize
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;infrastructure_subnet_id&lt;/code&gt; has been an input everywhere above. The networking module is where it gets made, carved out of one address space alongside two other prefixes, each with a different job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;aca_prefix&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cidrsubnet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address_space&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aca_subnet_newbits&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="nx"&gt;pe_prefix&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cidrsubnet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address_space&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;apim_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cidrsubnet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address_space&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_virtual_network"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vnet-${var.workload}-${var.environment}"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;address_space&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address_space&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Size is immutable once the environment exists. Changing this prefix is a rebuild of the&lt;/span&gt;
&lt;span class="c1"&gt;# environment and of every app and Dapr component in it.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_subnet"&lt;/span&gt; &lt;span class="s2"&gt;"container_apps"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"snet-aca-infra"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;virtual_network_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_virtual_network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;address_prefixes&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aca_prefix&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;delegation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aca-environment"&lt;/span&gt;

    &lt;span class="nx"&gt;service_delegation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Microsoft.App/environments"&lt;/span&gt;
      &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Microsoft.Network/virtualNetworks/subnets/action"&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="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_subnet"&lt;/span&gt; &lt;span class="s2"&gt;"private_endpoints"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"snet-pe"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;virtual_network_name&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_virtual_network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;address_prefixes&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pe_prefix&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;private_endpoint_network_policies&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Disabled"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_subnet"&lt;/span&gt; &lt;span class="s2"&gt;"apim"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"snet-apim"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;virtual_network_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_virtual_network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;address_prefixes&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apim_prefix&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;delegation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"apim"&lt;/span&gt;

    &lt;span class="nx"&gt;service_delegation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Microsoft.ApiManagement/service"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# v5 shape. v4 wrote service_endpoints = ["Microsoft.Storage", "Microsoft.EventHub"].&lt;/span&gt;
  &lt;span class="nx"&gt;service_endpoint&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Microsoft.Storage"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;service_endpoint&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Microsoft.EventHub"&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;aca_subnet_newbits = 7&lt;/code&gt; against a &lt;code&gt;/16&lt;/code&gt; produces a &lt;code&gt;/23&lt;/code&gt;, and that number is the only one in this article you cannot revise later. Learn's rule for a workload profiles environment is that "the minimum subnet size required for virtual network integration is &lt;code&gt;/27&lt;/code&gt;", and a few lines further down on the same page, "you can't modify subnet sizes after you create a Container Apps environment" (&lt;a href="https://learn.microsoft.com/azure/container-apps/custom-virtual-networks#subnet" rel="noopener noreferrer"&gt;custom-virtual-networks#subnet&lt;/a&gt;). An undersized subnet is not a resize. It is a new environment, a new set of apps, new Dapr components, and a cutover.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/27&lt;/code&gt; is legal and it caps hard:&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%2Flybhjqjop2z80eo9xxmi.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%2Flybhjqjop2z80eo9xxmi.png" alt="Container Apps subnet sizing: available IPs per CIDR prefix and the node ceiling it imposes on Dedicated profiles against the replica ceiling on Consumption" width="730" height="256"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nine Dedicated nodes is a small production environment on a good day, and a rollout in &lt;code&gt;revision_mode = "Single"&lt;/code&gt; runs the old and new revisions side by side, so the real ceiling during a deploy is roughly half the column. A &lt;code&gt;/23&lt;/code&gt; out of a &lt;code&gt;10.0.0.0/16&lt;/code&gt; costs you address space you were never going to allocate to anything else.&lt;/p&gt;

&lt;p&gt;Do not compute the size yourself from a reserved-IP count, because Microsoft states that count three ways. The body text on &lt;code&gt;custom-virtual-networks#subnet&lt;/code&gt; says Container Apps "automatically reserves 12 IP addresses". The footnote under the sizing table on that same page says 14, "which includes 5 IP addresses that the subnet reserves". The &lt;a href="https://learn.microsoft.com/azure/container-apps/workload-profiles-manage-cli#create-a-container-app-in-a-profile" rel="noopener noreferrer"&gt;workload profiles CLI page&lt;/a&gt; says 11, and concludes that a &lt;code&gt;/27&lt;/code&gt; has 21 available addresses, which contradicts the 18 in the table. Nobody has reconciled these. The sizing table is the source to trust, because it is the only one that resolves to node and replica limits rather than to an IP count, and its arithmetic agrees with itself. Read the replica column, double it for the rollout, and pick the row above that.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/21&lt;/code&gt; figure still circulating comes from the azurerm 4.51 docs and is stale by a factor of 64. The &lt;code&gt;/23&lt;/code&gt; minimum that sits next to it belongs to the legacy Consumption-only environment, and that is also where the delegation rule inverts rather than relaxes: a workload profiles subnet &lt;strong&gt;must&lt;/strong&gt; be delegated to &lt;code&gt;Microsoft.App/environments&lt;/code&gt;, and a Consumption-only subnet "must not be delegated to any services, including &lt;code&gt;Microsoft.App/environments&lt;/code&gt;". Copying a Consumption-era module into a workload profiles environment gets both halves wrong at once. The &lt;code&gt;actions&lt;/code&gt; list under &lt;code&gt;service_delegation&lt;/code&gt; is the one Azure fills in for that delegation anyway; write it or leave it out, you cannot change it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;azurerm_virtual_network&lt;/code&gt; also accepts an inline &lt;code&gt;subnet&lt;/code&gt; block, and a VNet that uses the inline form and standalone &lt;code&gt;azurerm_subnet&lt;/code&gt; resources at the same time fights itself on every plan. Pick one, and pick the standalone resources.&lt;/p&gt;

&lt;h3&gt;
  
  
  The NSG rules everyone pastes are the Consumption rules
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Workload profiles rules, not the Consumption-only set. The Consumption environment needs&lt;/span&gt;
&lt;span class="c1"&gt;# an outbound allow to AzureCloud on 443 and to 1.1.1.1/1.0.0.1 on 53; a workload profiles&lt;/span&gt;
&lt;span class="c1"&gt;# environment reaches its control plane over the same 443 rules the app already needs.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_network_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"container_apps"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"nsg-aca-${var.workload}-${var.environment}"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;

  &lt;span class="nx"&gt;security_rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"allow-apim-to-edge-proxy"&lt;/span&gt;
    &lt;span class="nx"&gt;priority&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="nx"&gt;direction&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Inbound"&lt;/span&gt;
    &lt;span class="nx"&gt;access&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Tcp"&lt;/span&gt;
    &lt;span class="nx"&gt;source_port_range&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_port_ranges&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"443"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"31443"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;source_address_prefix&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apim_prefix&lt;/span&gt;
    &lt;span class="nx"&gt;destination_address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aca_prefix&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;security_rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"allow-lb-health-probe"&lt;/span&gt;
    &lt;span class="nx"&gt;priority&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;110&lt;/span&gt;
    &lt;span class="nx"&gt;direction&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Inbound"&lt;/span&gt;
    &lt;span class="nx"&gt;access&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Tcp"&lt;/span&gt;
    &lt;span class="nx"&gt;source_port_range&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_port_range&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"30000-32767"&lt;/span&gt;
    &lt;span class="nx"&gt;source_address_prefix&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AzureLoadBalancer"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aca_prefix&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# An NSG is evaluated on both NICs, so the catch-all deny below would otherwise override the&lt;/span&gt;
  &lt;span class="c1"&gt;# default AllowVnetInBound at priority 65000 and break pod to pod traffic inside the subnet.&lt;/span&gt;
  &lt;span class="nx"&gt;security_rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"allow-intra-subnet-inbound"&lt;/span&gt;
    &lt;span class="nx"&gt;priority&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;
    &lt;span class="nx"&gt;direction&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Inbound"&lt;/span&gt;
    &lt;span class="nx"&gt;access&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;source_port_range&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_port_range&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;source_address_prefix&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aca_prefix&lt;/span&gt;
    &lt;span class="nx"&gt;destination_address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aca_prefix&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;security_rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"deny-other-inbound"&lt;/span&gt;
    &lt;span class="nx"&gt;priority&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;
    &lt;span class="nx"&gt;direction&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Inbound"&lt;/span&gt;
    &lt;span class="nx"&gt;access&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Deny"&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;source_port_range&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_port_range&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;source_address_prefix&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;security_rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"allow-entra"&lt;/span&gt;
    &lt;span class="nx"&gt;priority&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;130&lt;/span&gt;
    &lt;span class="nx"&gt;direction&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Outbound"&lt;/span&gt;
    &lt;span class="nx"&gt;access&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Tcp"&lt;/span&gt;
    &lt;span class="nx"&gt;source_port_range&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_port_range&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"443"&lt;/span&gt;
    &lt;span class="nx"&gt;source_address_prefix&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aca_prefix&lt;/span&gt;
    &lt;span class="nx"&gt;destination_address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AzureActiveDirectory"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Never deny 168.63.129.16. The environment stops working, and nothing in the portal says why.&lt;/span&gt;
  &lt;span class="nx"&gt;security_rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"allow-azure-platform-dns"&lt;/span&gt;
    &lt;span class="nx"&gt;priority&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;
    &lt;span class="nx"&gt;direction&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Outbound"&lt;/span&gt;
    &lt;span class="nx"&gt;access&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;source_port_range&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_port_range&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"53"&lt;/span&gt;
    &lt;span class="nx"&gt;source_address_prefix&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aca_prefix&lt;/span&gt;
    &lt;span class="nx"&gt;destination_address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"168.63.129.16"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;security_rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"deny-other-outbound"&lt;/span&gt;
    &lt;span class="nx"&gt;priority&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;
    &lt;span class="nx"&gt;direction&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Outbound"&lt;/span&gt;
    &lt;span class="nx"&gt;access&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Deny"&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;source_port_range&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_port_range&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;source_address_prefix&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_subnet_network_security_group_association"&lt;/span&gt; &lt;span class="s2"&gt;"container_apps"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_id&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_apps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;network_security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_network_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_apps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full module adds four more outbound allows on 443, to &lt;code&gt;MicrosoftContainerRegistry&lt;/code&gt;, &lt;code&gt;AzureFrontDoor.FirstParty&lt;/code&gt;, &lt;code&gt;AzureMonitor&lt;/code&gt; and the private endpoint prefix. What is missing is the interesting part. Most Container Apps NSGs in the wild were copied off the Consumption-only tab of &lt;a href="https://learn.microsoft.com/azure/container-apps/firewall-integration" rel="noopener noreferrer"&gt;firewall-integration&lt;/a&gt;, and on a workload profiles environment those extra rules grant reachability nobody needs:&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%2Folfnrfet82o6mb8mrhxr.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%2Folfnrfet82o6mb8mrhxr.png" alt="NSG rules from the Consumption-only guidance against what a workload profiles environment actually needs, including the inbound gateway subnet ports that only workload profiles require" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AzureActiveDirectory&lt;/code&gt; on 443 outbound sits on the workload profiles outbound list with a condition attached: "If you're using a managed identity, it's required" (&lt;a href="https://learn.microsoft.com/azure/container-apps/firewall-integration" rel="noopener noreferrer"&gt;firewall-integration&lt;/a&gt;). In this architecture that condition is never false, because every identity here is a managed identity and every one of them fetches its token over that path. Remove that rule and you do not break the platform; you break every grant the previous section set up, some hours later, when a cached token expires.&lt;/p&gt;

&lt;p&gt;Two more rules are not about ports. Never deny &lt;code&gt;168.63.129.16&lt;/code&gt;: the docs carry an explicit warning and the environment stops working. And the moment a catch-all inbound deny exists, an explicit intra-subnet inbound allow becomes mandatory, because an NSG is evaluated on both the source and the destination NIC and a rule at priority 4000 wins over the default &lt;code&gt;AllowVnetInBound&lt;/code&gt; at 65000. One more shows up only at apply time: service tags work in &lt;code&gt;source_address_prefix&lt;/code&gt; and not in &lt;code&gt;source_address_prefixes&lt;/code&gt;, so the plural form takes your &lt;code&gt;AzureLoadBalancer&lt;/code&gt; string and fails on the API call.&lt;/p&gt;

&lt;p&gt;The sharper point is that on an &lt;strong&gt;external&lt;/strong&gt; workload profiles environment, none of the inbound rules do anything at all. Traffic arrives through a public IP in the environment's managed resource group and reaches the app without ever transiting your subnet, so the NSG sees it only on the way out. &lt;code&gt;internal_only = true&lt;/code&gt; is what turns this file from documentation into enforcement, which is the Terraform-shaped version of the argument &lt;a href="https://dev.to/martin_oehlert/azure-front-door-api-management-and-container-apps-what-each-hop-trusts-38ik"&gt;Part 6&lt;/a&gt; made about Private Link at the gateway.&lt;/p&gt;

&lt;p&gt;None of those rules apply until the association exists, which is the second dashed edge on the module diagram near the top of this article. The networking module exports &lt;code&gt;nsg_association_id&lt;/code&gt; for callers who would rather have that ordering as a reference than as a hand-written prerequisite; the root module takes the blunter route and lists &lt;code&gt;module.networking&lt;/code&gt; in &lt;code&gt;depends_on&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Private DNS: the zone the private endpoint will not create for you
&lt;/h2&gt;

&lt;p&gt;Four services go behind private endpoints and they need five zones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# ACR Premium needs the region-specific data zone as well as the registry zone.&lt;/span&gt;
  &lt;span class="c1"&gt;# Without it the manifest resolves privately and the layer download falls back to the public endpoint.&lt;/span&gt;
  &lt;span class="nx"&gt;private_dns_zone_names&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cosmos&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"privatelink.documents.azure.com"&lt;/span&gt;
    &lt;span class="nx"&gt;servicebus&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"privatelink.servicebus.windows.net"&lt;/span&gt;
    &lt;span class="nx"&gt;keyvault&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"privatelink.vaultcore.azure.net"&lt;/span&gt;
    &lt;span class="nx"&gt;acr&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"privatelink.azurecr.io"&lt;/span&gt;
    &lt;span class="nx"&gt;acr_data&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.location}.data.privatelink.azurecr.io"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_dns_zone"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_dns_zone_names&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# private_dns_zone_group on the endpoint writes the records. It does not link the zone to the&lt;/span&gt;
&lt;span class="c1"&gt;# VNet, and an unlinked zone resolves to the public IP with no DNS error anywhere.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_dns_zone_virtual_network_link"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_private_dns_zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"link-${each.key}-${var.workload}-${var.environment}"&lt;/span&gt;
  &lt;span class="nx"&gt;private_dns_zone_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;virtual_network_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_virtual_network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;registration_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_endpoint"&lt;/span&gt; &lt;span class="s2"&gt;"container_registry"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pe-acr-${var.workload}-${var.environment}"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_id&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_endpoints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;

  &lt;span class="nx"&gt;private_service_connection&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"psc-acr"&lt;/span&gt;
    &lt;span class="nx"&gt;private_connection_resource_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_registry_id&lt;/span&gt;
    &lt;span class="nx"&gt;subresource_names&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"registry"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;is_manual_connection&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;private_dns_zone_group&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"acr"&lt;/span&gt;
    &lt;span class="nx"&gt;private_dns_zone_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="nx"&gt;azurerm_private_dns_zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"acr"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;azurerm_private_dns_zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"acr_data"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A private endpoint does exactly one DNS job for you, and it is not the one people assume. &lt;code&gt;private_dns_zone_group&lt;/code&gt; writes the A record for the endpoint's private IP into the zones you list. It does not link those zones to a VNet. Skip the link and every lookup falls through to public DNS, which answers with the public IP, and nothing reports a DNS failure anywhere: the application gets a connection refused or a 403 from a firewall, and the first four people who look at it will look at the firewall. The &lt;code&gt;for_each&lt;/code&gt; pairing zones with links is worth more than the typing it saves, because it makes an unlinked zone impossible to create.&lt;/p&gt;

&lt;p&gt;Three details in that file will only ever be checked by the service. &lt;code&gt;subresource_names&lt;/code&gt; is case-sensitive at the API and the casing is not consistent between services: &lt;code&gt;Sql&lt;/code&gt; for Cosmos NoSQL, &lt;code&gt;namespace&lt;/code&gt; for Service Bus, &lt;code&gt;vault&lt;/code&gt; for Key Vault, &lt;code&gt;registry&lt;/code&gt; for the registry. Cosmos in particular has one answer per API, so a Table or Gremlin account needs a different subresource and a different zone than the &lt;code&gt;privatelink.documents.azure.com&lt;/code&gt; above. Second, &lt;code&gt;privatelink.servicebus.windows.net&lt;/code&gt; is shared by Service Bus, Event Hubs, Relay and IoT Hub, so four unrelated services collide in one zone and one &lt;code&gt;for_each&lt;/code&gt; key. Third, ACR Premium needs two zones. The registry zone resolves the manifest; the region-specific &lt;code&gt;{region}.data.privatelink.azurecr.io&lt;/code&gt; zone resolves the data endpoint that serves the layers. Omit the second and a pull starts privately, then stalls or falls back partway through the download, which looks like a network problem and is a missing zone.&lt;/p&gt;

&lt;p&gt;The zone nobody remembers belongs to the environment itself, and it lives in the root module because the environment is not a private endpoint at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A private endpoint gets its records from private_dns_zone_group. The environment is not a&lt;/span&gt;
&lt;span class="c1"&gt;# private endpoint, so its default_domain zone is hand-built: wildcard for the apps, apex for&lt;/span&gt;
&lt;span class="c1"&gt;# the environment itself, both pointing at the internal load balancer address.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_dns_zone"&lt;/span&gt; &lt;span class="s2"&gt;"container_apps_environment"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_apps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment_default_domain&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;required_tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_dns_zone_virtual_network_link"&lt;/span&gt; &lt;span class="s2"&gt;"container_apps_environment"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"link-cae-${local.name_suffix}"&lt;/span&gt;
  &lt;span class="nx"&gt;private_dns_zone_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_private_dns_zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_apps_environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;virtual_network_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vnet_id&lt;/span&gt;
  &lt;span class="nx"&gt;registration_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;resolution_policy&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Default"&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;required_tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_dns_a_record"&lt;/span&gt; &lt;span class="s2"&gt;"container_apps_wildcard"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
  &lt;span class="nx"&gt;private_dns_zone_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_private_dns_zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_apps_environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;ttl&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
  &lt;span class="nx"&gt;records&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_apps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment_static_ip&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;required_tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both halves of that are exported attributes, &lt;code&gt;default_domain&lt;/code&gt; and &lt;code&gt;static_ip_address&lt;/code&gt;, so no data source and no hard-coded domain is involved. The apex record is the same shape with &lt;code&gt;name = "@"&lt;/code&gt;. This zone only belongs on an &lt;strong&gt;internal&lt;/strong&gt; environment: &lt;code&gt;static_ip_address&lt;/code&gt; is the internal load balancer address when &lt;code&gt;internal_load_balancer_enabled&lt;/code&gt; is true and a public IP when it is not, and publishing a public IP into a private zone is a confusing way to achieve nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Coming from azurerm v4
&lt;/h3&gt;

&lt;p&gt;Four v5 changes hit this stack, and the private DNS one is why the diff is large rather than interesting:&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%2Fxpqbxko89o7yyghfe1kc.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%2Fxpqbxko89o7yyghfe1kc.png" alt="AzureRM provider v4 to v5 breaking changes: renamed arguments, the service endpoint block syntax switch, the inverted Cosmos DB local auth flag, and where each one bites" width="800" height="259"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The link and record rows are mechanical: five zones against one VNet means five links, plus every A record resource. The row to slow down on is the Cosmos one, because it is a rename &lt;strong&gt;and&lt;/strong&gt; a polarity flip. Carry the boolean across unchanged and you have written &lt;code&gt;local_authentication_enabled = true&lt;/code&gt;, which turns key authentication back on in the same commit where you believed you turned it off, and the plan diff reads like a rename. The neighbouring services spell the same idea three more ways for good measure: Service Bus uses &lt;code&gt;local_auth_enabled = false&lt;/code&gt; and Key Vault uses &lt;code&gt;rbac_authorization_enabled = true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;None of the &lt;code&gt;container_app&lt;/code&gt; resources themselves carry a breaking change across either major. The whole tree validated on &lt;code&gt;~&amp;gt; 4.51&lt;/code&gt; and on &lt;code&gt;~&amp;gt; 5.6&lt;/code&gt;. Everything that hurt was in the network and the data services around them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The identity chain, and the edge Terraform cannot see
&lt;/h2&gt;

&lt;p&gt;One identity per app, and every grant it needs hanging off it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# Role GUIDs, not names. A renamed role keeps its ID, and a name costs a role-definitions&lt;/span&gt;
  &lt;span class="c1"&gt;# lookup on every plan.&lt;/span&gt;
  &lt;span class="nx"&gt;role_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;acr_pull&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"7f951dda-4ed3-4680-a7ca-43fe172d538d"&lt;/span&gt;
    &lt;span class="nx"&gt;kv_secrets_user&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"4633458b-17de-408a-b874-0445c86b69e6"&lt;/span&gt;
    &lt;span class="nx"&gt;sb_data_sender&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"69a216fc-b8fb-44d8-bc22-1f3c2cd27a39"&lt;/span&gt;
    &lt;span class="nx"&gt;sb_data_receiver&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;role_definition_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/subscriptions/${data.azurerm_client_config.current.subscription_id}/providers/Microsoft.Authorization/roleDefinitions"&lt;/span&gt;

  &lt;span class="c1"&gt;# Grants are scoped to the individual queue, never to the namespace.&lt;/span&gt;
  &lt;span class="nx"&gt;app_queue_pairs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;pair&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;setproduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_ids&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service_bus_queue_ids&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt;
    &lt;span class="s2"&gt;"${pair[0]}.${pair[1]}"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;app_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pair&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="nx"&gt;queue&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pair&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="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_user_assigned_identity"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_ids&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"id-${each.value}-${var.environment}"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# principal_id is unknown until apply, so Terraform orders the identity ahead of every grant&lt;/span&gt;
&lt;span class="c1"&gt;# below without any help. The edge it cannot see is the one from the container app to this&lt;/span&gt;
&lt;span class="c1"&gt;# grant; see the depends_on on the container-apps module call in the root module.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"acr_pull"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_ids&lt;/span&gt;

  &lt;span class="nx"&gt;scope&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container_registry_id&lt;/span&gt;
  &lt;span class="nx"&gt;role_definition_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.role_definition_prefix}/${local.role_ids.acr_pull}"&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_user_assigned_identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;principal_id&lt;/span&gt;

  &lt;span class="c1"&gt;# Correct for a managed identity and wrong for a user or a group: it suppresses the existence&lt;/span&gt;
  &lt;span class="c1"&gt;# check that fails with PrincipalNotFound while Entra is still replicating the new principal.&lt;/span&gt;
  &lt;span class="nx"&gt;principal_type&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ServicePrincipal"&lt;/span&gt;
  &lt;span class="nx"&gt;skip_service_principal_aad_check&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"service_bus_sender"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_queue_pairs&lt;/span&gt;

  &lt;span class="nx"&gt;scope&lt;/span&gt;                            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service_bus_queue_ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;role_definition_id&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.role_definition_prefix}/${local.role_ids.sb_data_sender}"&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_user_assigned_identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_id&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;principal_id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_type&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ServicePrincipal"&lt;/span&gt;
  &lt;span class="nx"&gt;skip_service_principal_aad_check&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Cosmos data-plane access is not azurerm_role_assignment. A Cosmos DB Built-in Data Contributor&lt;/span&gt;
&lt;span class="c1"&gt;# handed out through the control plane grants nothing at the data plane, and the apply is green.&lt;/span&gt;
&lt;span class="c1"&gt;# 0001 is Data Reader, 0002 is Data Contributor.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_cosmosdb_sql_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"data_contributor"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_ids&lt;/span&gt;

  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cosmosdb_resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;account_name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cosmosdb_account_name&lt;/span&gt;
  &lt;span class="nx"&gt;role_definition_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.cosmosdb_account_id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002"&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_user_assigned_identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;principal_id&lt;/span&gt;
  &lt;span class="nx"&gt;scope&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.cosmosdb_account_id}/dbs/${var.cosmosdb_database_name}"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two edges in that file run in opposite directions and only one of them exists. &lt;code&gt;principal_id&lt;/code&gt; is unknown until apply, so Terraform has to create the identity before it can even render the role assignment, and it orders them correctly with no help. The container app in the next module over references the identity and the registry login server, never the grant, so Terraform is equally free to create the app first and let the first revision fail its pull. That is the failed pull from the opening paragraph, and the &lt;code&gt;depends_on&lt;/code&gt; on the module call is the whole of the fix.&lt;/p&gt;

&lt;p&gt;The same grant carries two more failure modes that have nothing to do with ordering. A brand new service principal can return &lt;code&gt;PrincipalNotFound&lt;/code&gt; from the role assignment API while Entra is still replicating it, which &lt;code&gt;skip_service_principal_aad_check = true&lt;/code&gt; suppresses. That argument is correct for every managed identity and wrong for a user or a group, where the check is the only thing standing between a typo and a grant to nothing. And the one nothing in Terraform can fix: managed identity backends cache tokens per resource &lt;a href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/managed-identity-best-practice-recommendations#limitation-of-using-managed-identities-for-authorization" rel="noopener noreferrer"&gt;"for around 24 hours"&lt;/a&gt;, and Cosmos data-plane assignments propagate on their own schedule, so a green apply is evidence that a grant exists and not that it works yet. Removing a grant lags the same way, which matters more.&lt;/p&gt;

&lt;p&gt;The GUID map is not premature optimisation: Microsoft's own advice is to use IDs because "even if the role is renamed, the role ID does not change", and &lt;code&gt;role_definition_name&lt;/code&gt; makes the provider list role definitions on every plan. &lt;code&gt;app_queue_pairs&lt;/code&gt; comes from the same instinct. &lt;code&gt;setproduct&lt;/code&gt; over apps and queues produces one grant per app per queue, scoped to &lt;code&gt;azurerm_servicebus_queue.orders.id&lt;/code&gt;. Scoping to the namespace instead is the most common over-grant in this architecture, and it is one shorter &lt;code&gt;scope&lt;/code&gt; argument away.&lt;/p&gt;

&lt;p&gt;The Cosmos resource at the bottom breaks the intuition of anyone who already knows Azure RBAC. &lt;code&gt;azurerm_role_assignment&lt;/code&gt; with a Cosmos DB Built-in Data Contributor role does exist, does apply, and grants nothing at the data plane, because Cosmos keeps its own role system underneath the account. &lt;code&gt;azurerm_cosmosdb_sql_role_assignment&lt;/code&gt; takes a &lt;code&gt;sqlRoleDefinitions/...&lt;/code&gt; path, not a subscription-level role definition ID, and its &lt;code&gt;scope&lt;/code&gt; is a data-plane path like &lt;code&gt;&amp;lt;account&amp;gt;/dbs/orders&lt;/code&gt;. Built-in &lt;code&gt;...0001&lt;/code&gt; is Data Reader and &lt;code&gt;...0002&lt;/code&gt; is Data Contributor. This is what makes &lt;code&gt;local_authentication_enabled = false&lt;/code&gt; on the account survivable: turn keys off without the data-plane assignment and every read returns 403, so the two changes belong in the same commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  State, and the lease nothing expires
&lt;/h2&gt;

&lt;p&gt;One root module, one backend config per environment, one tfvars file per environment, and no workspaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;use_oidc&lt;/span&gt;             &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nx"&gt;use_azuread_auth&lt;/span&gt;     &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nx"&gt;storage_account_name&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sttfstateprod01"&lt;/span&gt;
&lt;span class="nx"&gt;container_name&lt;/span&gt;       &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tfstate"&lt;/span&gt;
&lt;span class="nx"&gt;key&lt;/span&gt;                  &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"container-apps/prod.tfstate"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;subscription_id&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"00000000-0000-0000-0000-000000000000"&lt;/span&gt;

&lt;span class="nx"&gt;workload&lt;/span&gt;      &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"orders"&lt;/span&gt;
&lt;span class="nx"&gt;environment&lt;/span&gt;   &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt;
&lt;span class="nx"&gt;location&lt;/span&gt;      &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"westeurope"&lt;/span&gt;
&lt;span class="nx"&gt;address_space&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.70.0.0/16"&lt;/span&gt;

&lt;span class="nx"&gt;cost_center&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GAZE"&lt;/span&gt;
&lt;span class="nx"&gt;owner&lt;/span&gt;       &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AZE"&lt;/span&gt;
&lt;span class="nx"&gt;project&lt;/span&gt;     &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"orders"&lt;/span&gt;

&lt;span class="nx"&gt;image_tag&lt;/span&gt;    &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.4.2"&lt;/span&gt;
&lt;span class="nx"&gt;min_replicas&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="nx"&gt;max_replicas&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;

&lt;span class="nx"&gt;log_retention_days&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;

&lt;span class="nx"&gt;dedicated_workload_profiles&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;D4&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;profile_type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"D4"&lt;/span&gt;
    &lt;span class="nx"&gt;minimum_count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="nx"&gt;maximum_count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# orders_api_key has no default and is deliberately absent here. Supply it as&lt;/span&gt;
&lt;span class="c1"&gt;# TF_VAR_orders_api_key from the pipeline, not from a file in git.&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="nb"&gt;cd &lt;/span&gt;infra
terraform init &lt;span class="nt"&gt;-reconfigure&lt;/span&gt; &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;envs/prod.backend.hcl
terraform plan &lt;span class="nt"&gt;-var-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;envs/prod.tfvars &lt;span class="nt"&gt;-out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tfplan
terraform apply tfplan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;use_azuread_auth = true&lt;/code&gt; is now a required option rather than an opt-in when you authenticate to the state account with Entra ID, and &lt;code&gt;resource_group_name&lt;/code&gt; moved from required to optional on the backend; it is needed only alongside &lt;code&gt;lookup_blob_endpoint&lt;/code&gt;. The &lt;code&gt;-reconfigure&lt;/code&gt; on &lt;code&gt;init&lt;/code&gt; lets the same working directory point at a different state blob without Terraform offering to migrate the old one.&lt;/p&gt;

&lt;p&gt;Refusing workspaces is a decision with a cost, so here are both halves. HashiCorp's own position is that "CLI workspaces within a working directory use the same backend", which makes them unsuitable for environments that differ in blast radius. The Azure-specific reason is uglier: the azurerm backend builds the workspace blob name by string concatenation, so a &lt;code&gt;staging&lt;/code&gt; workspace on &lt;code&gt;key = "container-apps/prod.tfstate"&lt;/code&gt; writes its state to a blob literally named &lt;code&gt;container-apps/prod.tfstateenv:staging&lt;/code&gt;. What you give up is per-environment configuration divergence. Anything dev needs that prod does not has to become a variable, and a variable that exists only to make dev cheaper is one somebody will eventually set in prod.&lt;/p&gt;

&lt;p&gt;The lock deserves more attention than the docs give it. All the backend documentation says is "supports state locking", and the mechanism is in &lt;a href="https://github.com/hashicorp/terraform/blob/main/internal/backend/remote-state/azure/client.go" rel="noopener noreferrer"&gt;the implementation&lt;/a&gt;: &lt;code&gt;Lock()&lt;/code&gt; acquires a blob lease on the state file with &lt;code&gt;LeaseDuration: -1&lt;/code&gt;, the Terraform lock ID travels as the proposed lease ID in the &lt;code&gt;x-ms-lease-id&lt;/code&gt; header, and the holder is recorded in the blob's &lt;code&gt;terraformlockid&lt;/code&gt; metadata. There is no lock table to provision, which is the pleasant half.&lt;/p&gt;

&lt;p&gt;The unpleasant half is that &lt;code&gt;-1&lt;/code&gt; means infinite, and infinite means nothing expires it. A runner that is hard-killed mid-apply leaves the state locked until a human runs &lt;code&gt;terraform force-unlock &amp;lt;LOCK_ID&amp;gt;&lt;/code&gt; or breaks the lease in the portal. That is a runbook entry, not a piece of folklore, and it is the reason every plan and apply in the next section passes &lt;code&gt;-lock-timeout=5m&lt;/code&gt; and the workflow sets &lt;code&gt;cancel-in-progress: false&lt;/code&gt;. The default reflex of &lt;code&gt;cancel-in-progress: true&lt;/code&gt; is precisely the wrong setting here: it cancels a running apply halfway through and strands the lease it was holding.&lt;/p&gt;

&lt;p&gt;Correct reasoning gets you the wrong answer on one RBAC detail. Storage Blob Data &lt;strong&gt;Reader&lt;/strong&gt; is not enough for &lt;code&gt;terraform plan&lt;/code&gt;, because plan takes the lock and taking the lock writes blob metadata. Grant Contributor scoped to the container, or run read-only plans with &lt;code&gt;-lock=false&lt;/code&gt; and accept that you can read a state file somebody else is halfway through writing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tags: the provider argument that does not exist
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;cost_center&lt;/code&gt;, &lt;code&gt;owner&lt;/code&gt; and &lt;code&gt;project&lt;/code&gt; are in that tfvars file because the policy in this tenant denies any resource missing &lt;code&gt;cost-center&lt;/code&gt;, &lt;code&gt;owner&lt;/code&gt;, &lt;code&gt;environment&lt;/code&gt; and &lt;code&gt;project&lt;/code&gt;. That is a provider-level problem in every IaC tool that has provider-level tags. azurerm is not one of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# azurerm has no provider-level default_tags, so the policy-required four go in a locals map and&lt;/span&gt;
&lt;span class="c1"&gt;# get assigned on every resource. merge() where a resource needs extras.&lt;/span&gt;
&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"cost-center"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cost_center&lt;/span&gt;
    &lt;span class="s2"&gt;"owner"&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;
    &lt;span class="s2"&gt;"environment"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;
    &lt;span class="s2"&gt;"project"&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_log_analytics_workspace"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"log-${local.name_suffix}"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;sku&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PerGB2018"&lt;/span&gt;
  &lt;span class="nx"&gt;retention_in_days&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log_retention_days&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;required_tags&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;tags = local.required_tags&lt;/code&gt; on every resource, &lt;code&gt;tags = merge(local.required_tags, { ... })&lt;/code&gt; where one needs extras, and a review that catches the resource where somebody forgot. The tracker will tell you otherwise if you let it: &lt;a href="https://github.com/hashicorp/terraform-provider-azurerm/issues/11682" rel="noopener noreferrer"&gt;issue #11682&lt;/a&gt; asking for &lt;code&gt;default_tags&lt;/code&gt; has 48 reactions and is closed as completed, while &lt;a href="https://github.com/hashicorp/terraform-provider-azurerm/pull/31108" rel="noopener noreferrer"&gt;PR #31108&lt;/a&gt; adding the argument has been open and unmerged since November 2025. A closed-as-completed issue is not a released feature.&lt;/p&gt;

&lt;p&gt;The tooling will not correct you either. Write &lt;code&gt;default_tags { tags = local.required_tags }&lt;/code&gt; into a provider block, run &lt;code&gt;terraform validate&lt;/code&gt;, and you get &lt;code&gt;Success!&lt;/code&gt;, because &lt;code&gt;validate&lt;/code&gt; never decodes the &lt;code&gt;provider&lt;/code&gt; block, and neither does &lt;code&gt;plan&lt;/code&gt; when no resource needs a provider instance: a configuration whose only content was that invented block also returned &lt;code&gt;No changes.&lt;/code&gt; "It validated" is not evidence that an argument exists. &lt;code&gt;terraform providers schema -json&lt;/code&gt; is the answer: the azurerm provider block carries no tag attribute and exactly one nested block, &lt;code&gt;features&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline: plan on the PR, apply behind an environment gate
&lt;/h2&gt;



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

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&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;TerraformContainerAppsDemo/infra/**"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&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;TerraformContainerAppsDemo/infra/**"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&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="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform-${{ github.ref }}&lt;/span&gt;
  &lt;span class="c1"&gt;# Never true here. The azurerm backend lock is an infinite blob lease, so a cancelled apply&lt;/span&gt;
  &lt;span class="c1"&gt;# strands the state locked until someone runs terraform force-unlock.&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;TF_IN_AUTOMATION&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="na"&gt;ARM_USE_OIDC&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="na"&gt;ARM_USE_AZUREAD&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="na"&gt;ARM_CLIENT_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.AZURE_CLIENT_ID }}&lt;/span&gt;
  &lt;span class="na"&gt;ARM_TENANT_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.AZURE_TENANT_ID }}&lt;/span&gt;
  &lt;span class="na"&gt;ARM_SUBSCRIPTION_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.AZURE_SUBSCRIPTION_ID }}&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;plan&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;Plan (prod)&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&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="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;pull-requests&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;defaults&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TerraformContainerAppsDemo/infra&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&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/checkout@v5&lt;/span&gt;

      &lt;span class="pi"&gt;-&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;hashicorp/setup-terraform@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;terraform_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.16.3&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;Init&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;init&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;terraform init -input=false -backend-config=envs/prod.backend.hcl&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;Validate&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;validate&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;terraform validate -no-color&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;Plan&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plan&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;TF_VAR_orders_api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ORDERS_API_KEY }}&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;terraform plan -input=false -no-color -lock-timeout=5m -var-file=envs/prod.tfvars -out=tfplan&lt;/span&gt;
        &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&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;Fail if plan errored&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.plan.outcome == 'failure'&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;exit &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;

      &lt;span class="c1"&gt;# A saved plan carries sensitive values in cleartext and any user with repo read can&lt;/span&gt;
      &lt;span class="c1"&gt;# download an artifact. Keep the retention short.&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;Upload plan&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event_name == 'push'&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/upload-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfplan&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TerraformContainerAppsDemo/infra/tfplan&lt;/span&gt;
          &lt;span class="na"&gt;retention-days&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;apply&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;Apply (prod)&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plan&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event_name == 'push' &amp;amp;&amp;amp; github.ref == 'refs/heads/main'&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="c1"&gt;# The approval gate is this key and nothing else. Required reviewers, wait timers and&lt;/span&gt;
    &lt;span class="c1"&gt;# deployment branch rules are configured on the environment in repo settings.&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&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="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;defaults&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TerraformContainerAppsDemo/infra&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&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/checkout@v5&lt;/span&gt;

      &lt;span class="pi"&gt;-&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;hashicorp/setup-terraform@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;terraform_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.16.3&lt;/span&gt;
          &lt;span class="na"&gt;terraform_wrapper&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

      &lt;span class="pi"&gt;-&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/download-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfplan&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TerraformContainerAppsDemo/infra&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;Init&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;terraform init -input=false -backend-config=envs/prod.backend.hcl&lt;/span&gt;

      &lt;span class="c1"&gt;# No -var-file and no -auto-approve: a saved plan takes no further planning options, and&lt;/span&gt;
      &lt;span class="c1"&gt;# passing the file is the approval.&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;Apply&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;terraform apply -input=false -lock-timeout=5m tfplan&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trimmed step is a &lt;code&gt;github-script&lt;/code&gt; block that posts the plan as a PR comment and updates that comment on every push instead of stacking new ones. Four smaller choices in the file get a line each. &lt;code&gt;id-token: write&lt;/code&gt; sits on each job and never at workflow level, because at workflow level every job in the file holds a token exchangeable for Azure credentials, including the ones that only run a linter. &lt;code&gt;azure/login&lt;/code&gt; is absent because the azurerm provider performs the OIDC exchange itself. The apply job passes a saved plan with no &lt;code&gt;-var-file&lt;/code&gt; and no &lt;code&gt;-auto-approve&lt;/code&gt;, since a saved plan accepts no further planning options and handing it over is the approval. And &lt;code&gt;terraform validate&lt;/code&gt; needs no Azure credentials at all, because &lt;code&gt;subscription_id&lt;/code&gt; is required for plan and apply and not for validate.&lt;/p&gt;

&lt;p&gt;Everything that actually breaks in this file breaks on one string. The federated credential matches the &lt;code&gt;sub&lt;/code&gt; claim on the token GitHub mints, and that claim changes shape per trigger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repo:OWNER/REPO:pull_request
repo:OWNER/REPO:ref:refs/heads/main
repo:OWNER/REPO:environment:prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three subjects, so three federated credentials on the identity. The precedence rule is what makes this a trap rather than an inconvenience: GitHub uses the environment name whenever the job references an environment, &lt;code&gt;pull_request&lt;/code&gt; only if the job references no environment and the trigger was a pull request, and the branch only when neither applies (&lt;a href="https://docs.github.com/en/actions/reference/security/oidc#example-subject-claims" rel="noopener noreferrer"&gt;subject claim examples&lt;/a&gt;). Add &lt;code&gt;environment: prod&lt;/code&gt; to the plan job so it can read a secret and you have silently rewritten that job's own subject, its credential stops matching, and the &lt;code&gt;AADSTS700213&lt;/code&gt;-class error you get back says nothing about environments.&lt;/p&gt;

&lt;p&gt;The segment key is singular &lt;code&gt;ref:&lt;/code&gt;. HashiCorp's own azurerm OIDC guide prints &lt;code&gt;refs:refs/heads/main&lt;/code&gt;; GitHub mints the token, GitHub uses &lt;code&gt;ref:&lt;/code&gt;, and the plural form is a typo that will cost you an afternoon because the failure looks like a trust configuration problem rather than a spelling one.&lt;/p&gt;

&lt;p&gt;Then there is the change that made every older tutorial wrong. "Repositories created after July 15, 2026 now use an immutable default subject format that includes both the owner ID and repository ID" (&lt;a href="https://docs.github.com/en/actions/reference/security/oidc#immutable-subject-claims" rel="noopener noreferrer"&gt;immutable subject claims&lt;/a&gt;), which looks like &lt;code&gt;repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/BRANCH&lt;/code&gt;. Repos created before that date keep the old format until they opt in, and a rename or a transfer after it flips them. The IDs cannot be stripped back out with &lt;code&gt;include_claim_keys&lt;/code&gt;. Copy a subject string out of any pre-2026 blog post, including a good one, and it will not match on a new repository. Read the real &lt;code&gt;sub&lt;/code&gt; off a token, or build the string from the owner and repo IDs the API gives you. There is a ceiling on that string, too: an application or user-assigned identity accepts a maximum of 20 federated identity credentials and wildcards are not supported, so one credential per subject is a budget rather than a pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Drift detection that is still believed in week three
&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;17&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1-5"&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;drift&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="c1"&gt;# If the prod environment has required reviewers, this scheduled run sits waiting for&lt;/span&gt;
    &lt;span class="c1"&gt;# approval. Give drift its own read-scoped environment with no reviewers when that happens.&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&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="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;issues&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;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# -detailed-exitcode is what makes this possible: 0 no changes, 1 error, 2 changes.&lt;/span&gt;
      &lt;span class="c1"&gt;# Without it a plan with changes and a plan without both exit 0.&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;Plan with detailed exit code&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plan&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;TF_VAR_orders_api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ORDERS_API_KEY }}&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;terraform plan -input=false -no-color -lock-timeout=5m -detailed-exitcode -var-file=envs/prod.tfvars&lt;/span&gt;
        &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&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;Fail the job on a real error&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.plan.outputs.exitcode == '1'&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;exit &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;

      &lt;span class="c1"&gt;# A detector that only opens issues trains everyone to ignore it within two weeks.&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;Close the drift issue when clean&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.plan.outputs.exitcode == '0'&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;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;gh issue list --state open --label drift --json number --jq '.[].number' \&lt;/span&gt;
            &lt;span class="s"&gt;| xargs -r -I{} gh issue close {} --comment "Plan is clean again."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The checkout, setup and init steps are the same three as the plan job, and the step that opens or comments on the issue on exit code 2 is in the companion repo. &lt;code&gt;continue-on-error: true&lt;/code&gt; on the plan step is not optional: exit code 2 is a success for our purposes and a failure as far as Actions is concerned, so without it the step fails, every conditional after it is skipped, and you get a red run that tells you nothing about whether prod drifted.&lt;/p&gt;

&lt;p&gt;The step most drift workflows are missing is the last one. Opening an issue when the plan is dirty is the easy half; closing it when the plan comes back clean keeps anyone reading the issues in week three. A detector that only ever adds to a pile teaches the team to filter the label, and after that it is a scheduled job that costs money and detects nothing.&lt;/p&gt;

&lt;p&gt;Whether this ever runs comes down to scheduling rules the docs bury. Scheduled workflows only ever run from the default branch, so you cannot test a schedule change on a branch and &lt;code&gt;workflow_dispatch&lt;/code&gt; is how you try it. GitHub delays scheduled runs during high load at the top of the hour, hence &lt;code&gt;17&lt;/code&gt; rather than &lt;code&gt;0&lt;/code&gt;. And a public repository has its scheduled workflows disabled after 60 days without activity, which lands exactly when drift has had time to accumulate and nobody is watching. The &lt;code&gt;environment: prod&lt;/code&gt; on this job exists to match the third federated credential, and it brings the approval gate with it: if that environment has required reviewers, the scheduled run sits waiting for a human who is not expecting to approve anything. Give drift a read-scoped environment of its own with its own credential.&lt;/p&gt;

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

&lt;p&gt;A grant before a pull. An NSG before an environment. A zone link before a lookup. A data-plane role before a read. None of those four are things your configuration has any reason to reference, which is why &lt;code&gt;depends_on&lt;/code&gt; and a handful of &lt;code&gt;precondition&lt;/code&gt; blocks are where you write them down, and why every silent failure in this article is the same failure.&lt;/p&gt;

&lt;p&gt;The full tree is in the &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/TerraformContainerAppsDemo" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;, including the two workflow files and the steps trimmed out of the samples above. It validates and &lt;code&gt;fmt&lt;/code&gt;s clean on Terraform 1.16.3 with azurerm 5.6.0, and it deploys nothing on its own.&lt;/p&gt;

&lt;p&gt;One question, and it splits teams cleanly: does your Terraform run &lt;code&gt;plan&lt;/code&gt; on every pull request, or only after the merge?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>terraform</category>
      <category>infrastructureascode</category>
      <category>containerapps</category>
    </item>
    <item>
      <title>Azure Front Door, API Management and Container Apps: What Each Hop Trusts</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 18 Sep 2026 09:29:39 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/azure-front-door-api-management-and-container-apps-what-each-hop-trusts-38ik</link>
      <guid>https://dev.to/martin_oehlert/azure-front-door-api-management-and-container-apps-what-each-hop-trusts-38ik</guid>
      <description>&lt;p&gt;You can put Front Door, API Management and an internal Container Apps environment in front of &lt;code&gt;orders-api&lt;/code&gt; and still ship an app that takes a token from any workload in your tenant. Container Apps authentication checks that API Management's &lt;strong&gt;managed identity&lt;/strong&gt; token came from your tenant, and checks nothing else, so a workload with any identity calls the app directly and names whichever end user it likes. Entra closes that with one setting. The four hops in front of the app each run on a different answer to the same question, and only two of those answers are a credential.&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%2Fj4lfk2o3r93o5pjmka1i.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%2Fj4lfk2o3r93o5pjmka1i.png" alt="Network topology: Front Door Premium, API Management Standard v2 with a private endpoint and VNet integration, an internal Container Apps environment, and the four hops between them" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Front Door takes TLS, the WAF and a per-client rate limit off the gateway's hands, which leaves API Management deciding who may call and Container Apps running the code. The scenario is the order API from &lt;a href="https://dev.to/martin_oehlert/azure-functions-to-container-apps-one-endpoint-at-a-time-2e1"&gt;Part 5&lt;/a&gt;, with the Function App gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape you land on, and the tiers it forces
&lt;/h2&gt;

&lt;p&gt;Each tier below is the cheapest one with a feature the topology cannot do without.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Front Door Premium.&lt;/strong&gt; On Standard, "only custom rules are supported": the managed Default Rule Set and Bot Manager are &lt;a href="https://learn.microsoft.com/azure/web-application-firewall/afds/afds-overview" rel="noopener noreferrer"&gt;Premium&lt;/a&gt;, and so are &lt;a href="https://learn.microsoft.com/azure/frontdoor/origin-security" rel="noopener noreferrer"&gt;Private Link origins&lt;/a&gt;. Either reason alone settles it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API Management Standard v2.&lt;/strong&gt; The gateway has to accept Front Door's traffic privately and send its own traffic into the VNet that holds the Container Apps environment. Those are separate networking features, and the &lt;a href="https://learn.microsoft.com/azure/api-management/virtual-network-concepts" rel="noopener noreferrer"&gt;tier table&lt;/a&gt; decides which instances get both:&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%2Flls2v00zgxbhy7eag5je.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%2Flls2v00zgxbhy7eag5je.png" alt="API Management networking models by tier: classic VNet injection, Premium v2 injection, v2 VNet integration, and inbound private endpoints" width="800" height="259"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two constraints choose for you, and both bite late. Classic Premium looks like the enterprise answer and is the tier that blocks you: "In the classic API Management tiers, private endpoints aren't supported in instances injected in an internal or external virtual network." For the v2 tiers Microsoft's &lt;a href="https://learn.microsoft.com/azure/api-management/private-endpoint" rel="noopener noreferrer"&gt;private endpoint guide&lt;/a&gt; says to "combine inbound private endpoints to Standard v2 instances with outbound virtual network integration to provide end-to-end network isolation", which leaves Standard v2 as the lowest tier that does both.&lt;/p&gt;

&lt;p&gt;The other is an ordering rule, and it breaks pipelines. Integration on its own leaves the gateway and developer portal endpoints &lt;a href="https://learn.microsoft.com/azure/api-management/integrate-vnet-outbound" rel="noopener noreferrer"&gt;publicly reachable&lt;/a&gt;, so you switch public network access off once the private endpoint exists. You can only do that &lt;a href="https://learn.microsoft.com/azure/api-management/private-endpoint#optionally-disable-public-network-access" rel="noopener noreferrer"&gt;on an instance that already exists&lt;/a&gt;, never in the deployment that creates it, which means the gateway is public for as long as it takes you to run the second deployment. Give it an integration subnet of its own: at least /27, delegated to &lt;code&gt;Microsoft.Web/serverFarms&lt;/code&gt;, with a network security group attached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Container Apps: a workload profiles environment, internal, in your own VNet.&lt;/strong&gt; You pick accessibility once, when you create the environment, and an internal one has &lt;a href="https://learn.microsoft.com/azure/container-apps/ingress-overview#external-and-internal-ingress" rel="noopener noreferrer"&gt;no public endpoint&lt;/a&gt;. The subnet is fixed at creation too, and you &lt;a href="https://learn.microsoft.com/azure/container-apps/custom-virtual-networks" rel="noopener noreferrer"&gt;cannot resize it afterwards&lt;/a&gt;: /27 minimum, delegated to &lt;code&gt;Microsoft.App/environments&lt;/code&gt;. Size it for the most revisions you expect to keep active at once. Single revision mode briefly doubles address use during a revision change, and &lt;code&gt;orders-api&lt;/code&gt; runs in multiple revision mode, where every active revision keeps its own &lt;code&gt;minReplicas&lt;/code&gt; running on the same subnet (one address per 10 replicas on the Consumption profile, one per node on a dedicated one).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service Bus Premium.&lt;/strong&gt; Service Bus private endpoints are &lt;a href="https://learn.microsoft.com/azure/service-bus-messaging/private-link-service" rel="noopener noreferrer"&gt;a Premium feature&lt;/a&gt;, and the data hop at the end of this article runs over them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Front Door: what the edge actually does for you
&lt;/h2&gt;

&lt;h3&gt;
  
  
  WAF on a JSON API
&lt;/h3&gt;

&lt;p&gt;The WAF policy is a separate resource from the profile, and a security policy attaches it to the custom domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resource wafPolicy 'Microsoft.Network/FrontDoorWebApplicationFirewallPolicies@2025-03-01' = {
  name: 'wafOrdersApi'
  location: 'global'
  sku: { name: 'Premium_AzureFrontDoor' }   // defaults to Classic_AzureFrontDoor when omitted
  properties: {
    policySettings: {
      enabledState: 'Enabled'
      mode: 'Detection'
    }
    managedRules: {
      managedRuleSets: [
        {
          ruleSetType: 'Microsoft_DefaultRuleSet'
          ruleSetVersion: '2.2'
          ruleSetAction: 'Block'
        }
      ]
    }
    customRules: {
      rules: [
        {
          name: 'ApiPerClientRateLimit'
          priority: 10
          enabledState: 'Enabled'
          ruleType: 'RateLimitRule'
          rateLimitDurationInMinutes: 5
          rateLimitThreshold: 3000
          matchConditions: [
            {
              matchVariable: 'RequestUri'
              operator: 'Contains'
              matchValue: [ '/api/' ]
            }
          ]
          action: 'Block'
        }
      ]
    }
  }
}

resource wafAttachment 'Microsoft.Cdn/profiles/securityPolicies@2025-06-01' = {
  parent: frontDoorProfile
  name: 'waf-api-contoso'
  properties: {
    parameters: {
      type: 'WebApplicationFirewall'
      wafPolicy: { id: wafPolicy.id }
      associations: [
        {
          domains: [ { id: apiCustomDomain.id } ]
          patternsToMatch: [ '/*' ]
        }
      ]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start in &lt;code&gt;mode: 'Detection'&lt;/code&gt; on purpose. DRS 2.x uses &lt;a href="https://learn.microsoft.com/azure/web-application-firewall/afds/waf-front-door-drs" rel="noopener noreferrer"&gt;&lt;strong&gt;anomaly scoring&lt;/strong&gt;&lt;/a&gt;: a request is acted on once its score reaches 5, which one Critical match does on its own, and in Detection mode that action is only a log entry. Microsoft's rollout guidance is detection, then tuning, then prevention, and "the whole process might take several weeks." Do the tuning in Bicep or the CLI: changing the rule set version in the portal "resets all previous customizations".&lt;/p&gt;

&lt;p&gt;Expect &lt;code&gt;Authorization&lt;/code&gt; to be your first &lt;a href="https://learn.microsoft.com/azure/web-application-firewall/afds/waf-front-door-exclusion" rel="noopener noreferrer"&gt;exclusion&lt;/a&gt;, because Entra access tokens in a request header "can contain special characters that trigger a false positive detection". The WAF also &lt;a href="https://learn.microsoft.com/azure/web-application-firewall/afds/waf-faq" rel="noopener noreferrer"&gt;"doesn't support content-encoding"&lt;/a&gt;, so a client that gzips its POST body gets no meaningful body inspection.&lt;/p&gt;

&lt;p&gt;The rate limit rule is narrower than its property names suggest. It counts requests &lt;a href="https://learn.microsoft.com/azure/web-application-firewall/afds/waf-front-door-rate-limit" rel="noopener noreferrer"&gt;"from each socket IP address"&lt;/a&gt; over a fixed window of one or five minutes, and once a client crosses the threshold, "all traffic matching that rate limiting rule is blocked for the remainder of the fixed window." The &lt;code&gt;action&lt;/code&gt; in the rule above is one of only two a rate limit rule accepts, Log or Block. Counters live on individual Front Door servers, so low thresholds leak: below about 200 requests a minute "you might see some requests above the threshold get through". The five-minute window with a larger threshold is the more accurate shape, and you take the number itself from a week of Detection logs.&lt;/p&gt;

&lt;p&gt;Layer 3, 4 and 7 &lt;a href="https://learn.microsoft.com/azure/frontdoor/front-door-ddos" rel="noopener noreferrer"&gt;DDoS protection&lt;/a&gt; arrives with the profile whether you write a WAF policy or not.&lt;/p&gt;

&lt;h3&gt;
  
  
  One origin, and it is the gateway
&lt;/h3&gt;

&lt;p&gt;The origin is API Management: Front Door cannot see apps behind an internal environment, and the per-path routing it did during the migration now happens in the gateway. If you leave API Management out, the Private Link origin is the environment itself, and Part 5 covered why &lt;a href="https://dev.to/martin_oehlert/azure-functions-to-container-apps-one-endpoint-at-a-time-2e1#going-private-takes-the-whole-environment"&gt;that takes every app in it private at once&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resource apimOriginGroup 'Microsoft.Cdn/profiles/originGroups@2025-06-01' = {
  parent: frontDoorProfile
  name: 'apim-gateway'
  properties: {
    loadBalancingSettings: {
      sampleSize: 4
      successfulSamplesRequired: 3
    }
    healthProbeSettings: {
      probePath: '/status-0123456789abcdef'
      probeRequestType: 'GET'
      probeProtocol: 'Https'
      probeIntervalInSeconds: 30
    }
  }
}

resource apimOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2025-06-01' = {
  parent: apimOriginGroup
  name: 'apim-primary'
  properties: {
    hostName: '${apim.name}.azure-api.net'
    originHostHeader: '${apim.name}.azure-api.net'
    httpsPort: 443
    priority: 1
    weight: 1000
    enforceCertificateNameCheck: true
    sharedPrivateLinkResource: {
      privateLink: { id: apim.id }
      groupId: 'Gateway'
      privateLinkLocation: apim.location
      requestMessage: 'Front Door Premium to the orders gateway'
    }
  }
}

resource apiEndpoint 'Microsoft.Cdn/profiles/afdEndpoints@2025-06-01' = {
  parent: frontDoorProfile
  name: 'orders-api'
  location: 'global'
  properties: { enabledState: 'Enabled' }
}

resource apiRoute 'Microsoft.Cdn/profiles/afdEndpoints/routes@2025-06-01' = {
  parent: apiEndpoint
  name: 'api'
  dependsOn: [ apimOrigin ]
  properties: {
    customDomains: [ { id: apiCustomDomain.id } ]
    originGroup: { id: apimOriginGroup.id }
    patternsToMatch: [ '/api/*' ]
    supportedProtocols: [ 'Https' ]
    forwardingProtocol: 'HttpsOnly'
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploying this does not connect anything yet. Front Door creates a private endpoint that shows up as a pending connection on the API Management instance, and traffic flows only after someone &lt;a href="https://learn.microsoft.com/azure/frontdoor/standard-premium/how-to-enable-private-link-apim" rel="noopener noreferrer"&gt;approves it&lt;/a&gt; with &lt;code&gt;az network private-endpoint-connection approve&lt;/code&gt;. Put that step in the pipeline, or the deployment succeeds and serves nothing. A Private Link origin can't share an origin group with public origins, and it has a ceiling worth writing down before you size anything: "each Front Door regional cluster has a limit of 7200 RPS (requests per second) per Front Door profile", and requests past it come back &lt;a href="https://learn.microsoft.com/azure/frontdoor/private-link#frequently-asked-questions" rel="noopener noreferrer"&gt;429&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The route has no &lt;code&gt;cacheConfiguration&lt;/code&gt;, and leaving it out is how you &lt;a href="https://learn.microsoft.com/azure/templates/microsoft.cdn/profiles/afdendpoints/routes" rel="noopener noreferrer"&gt;disable caching&lt;/a&gt; for an API. The managed certificate on &lt;code&gt;apiCustomDomain&lt;/code&gt; has a condition of its own: it renews by itself only if the domain's CNAME &lt;a href="https://learn.microsoft.com/azure/frontdoor/domain" rel="noopener noreferrer"&gt;"points directly to a Front Door endpoint"&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The probe tests the gateway
&lt;/h3&gt;

&lt;p&gt;You are configuring this probe for a day that has not arrived yet. With one gateway region there is one origin, and a &lt;a href="https://learn.microsoft.com/azure/frontdoor/best-practices#best-practices-for-health-probes" rel="noopener noreferrer"&gt;single-origin group&lt;/a&gt; routes to it whatever the probe reports. The probe starts deciding anything on the day you add a second region, which is also the day you would least like to discover you pointed it at the wrong path.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/status-0123456789abcdef&lt;/code&gt; is the path Microsoft's &lt;a href="https://learn.microsoft.com/azure/api-management/front-door-api-management#configure-azure-front-door" rel="noopener noreferrer"&gt;Front Door and API Management guide&lt;/a&gt; configures. It is &lt;a href="https://learn.microsoft.com/azure/api-management/api-management-gateways-overview#gateway-health-check-endpoint" rel="noopener noreferrer"&gt;built into every managed gateway&lt;/a&gt; outside the Consumption tier and answers 200 when the gateway is up, which is all a probe choosing between gateways needs to know. On a Private Link origin, health probes &lt;a href="https://learn.microsoft.com/azure/frontdoor/private-link#frequently-asked-questions" rel="noopener noreferrer"&gt;take the same network path as traffic&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;An app route would fail as a probe target. The gateway rejects a probe that carries no subscription key or token, and the &lt;code&gt;/health&lt;/code&gt; endpoint from &lt;a href="https://dev.to/martin_oehlert/net-aspire-orchestrating-cloud-native-apps-15f3"&gt;Part 4&lt;/a&gt; is mapped in Development only. When every origin fails, Front Door &lt;a href="https://learn.microsoft.com/azure/frontdoor/health-probes" rel="noopener noreferrer"&gt;"considers all origins unhealthy and routes traffic in a round robin distribution across all of them"&lt;/a&gt;: the probe fails open. App health is decided one layer down anyway, by readiness probes on the container apps and by a circuit breaker on the API Management backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  API Management: the only layer that knows the caller
&lt;/h2&gt;

&lt;p&gt;Front Door knows a socket address and Container Apps knows a token issuer. Only API Management knows which person or partner is calling and on which product, so most of this architecture's configuration lives in its policies.&lt;/p&gt;

&lt;h3&gt;
  
  
  A backend per app, reached through the VNet
&lt;/h3&gt;

&lt;p&gt;There is no backend for "the Container Apps environment". An environment has no single URL: each app has its own FQDN and the environment's proxy routes on the host header. API Management gets one backend entity per app, and the VNet gets a private DNS zone that resolves those names to the environment's internal address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;param acaDefaultDomain string   // az containerapp env show --query properties.defaultDomain
param acaStaticIp string        // az containerapp env show --query properties.staticIp

resource ordersBackend 'Microsoft.ApiManagement/service/backends@2024-05-01' = {
  parent: apim
  name: 'orders-api'
  properties: {
    url: 'https://orders-api.${acaDefaultDomain}'
    protocol: 'http'
  }
}

resource acaZone 'Microsoft.Network/privateDnsZones@2024-06-01' = {
  name: acaDefaultDomain
  location: 'global'
}

resource acaRecords 'Microsoft.Network/privateDnsZones/A@2024-06-01' = [for recordName in [ '*', '@' ]: {
  parent: acaZone
  name: recordName
  properties: {
    ttl: 3600
    aRecords: [ { ipv4Address: acaStaticIp } ]
  }
}]

resource acaZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = {
  parent: acaZone
  name: 'vnet-orders'
  location: 'global'
  properties: {
    registrationEnabled: false
    virtualNetwork: { id: vnet.id }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The zone is &lt;a href="https://learn.microsoft.com/azure/container-apps/private-endpoints-with-dns" rel="noopener noreferrer"&gt;named after the environment's default domain&lt;/a&gt;, and it has to be linked to the VNet that API Management integrates with, or the gateway resolves the public name and gets nowhere. Leave the host header alone: &lt;a href="https://learn.microsoft.com/azure/architecture/best-practices/host-name-preservation" rel="noopener noreferrer"&gt;"by default, API Management overrides the host name that's sent to the back end"&lt;/a&gt; with the host of the URL it calls, which is the app FQDN the environment proxy routes on. Reference it from policy with &lt;code&gt;backend-id&lt;/code&gt; rather than &lt;code&gt;base-url&lt;/code&gt;, because Part 5 covered &lt;a href="https://dev.to/martin_oehlert/azure-functions-to-container-apps-one-endpoint-at-a-time-2e1#what-you-do-when-it-goes-wrong"&gt;the trap&lt;/a&gt; in mixing the two across scopes.&lt;/p&gt;

&lt;p&gt;The failure that survives all of this is a 404. Aspire &lt;a href="https://aspire.dev/deployment/azure/container-apps/" rel="noopener noreferrer"&gt;maps an internal endpoint to internal-only ingress&lt;/a&gt;, so &lt;code&gt;azd infra gen&lt;/code&gt; writes &lt;code&gt;ingress.external: false&lt;/code&gt; for every project the AppHost doesn't mark with &lt;code&gt;WithExternalHttpEndpoints()&lt;/code&gt;, and on an internal environment that setting hides the app from the rest of the VNet as well: &lt;a href="https://learn.microsoft.com/azure/container-apps/connect-apps" rel="noopener noreferrer"&gt;the DNS name resolves and the TLS handshake succeeds, but the proxy rejects the request&lt;/a&gt; with a 404. It reads as a routing bug and is a visibility setting. Add &lt;code&gt;WithExternalHttpEndpoints()&lt;/code&gt; to &lt;code&gt;orders-api&lt;/code&gt; in the AppHost; on an internal environment, external ingress still keeps the app off the internet.&lt;/p&gt;

&lt;h3&gt;
  
  
  One inbound policy, in the order it has to run
&lt;/h3&gt;

&lt;p&gt;The policy on the orders API does the rest of this section's work, and the order of the elements is load-bearing:&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;policies&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;inbound&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;base&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;check-header&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"X-Azure-FDID"&lt;/span&gt; &lt;span class="na"&gt;failed-check-httpcode=&lt;/span&gt;&lt;span class="s"&gt;"403"&lt;/span&gt;
                  &lt;span class="na"&gt;failed-check-error-message=&lt;/span&gt;&lt;span class="s"&gt;"Invalid request."&lt;/span&gt; &lt;span class="na"&gt;ignore-case=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;value&amp;gt;&lt;/span&gt;{{FrontDoorId}}&lt;span class="nt"&gt;&amp;lt;/value&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/check-header&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;validate-azure-ad-token&lt;/span&gt; &lt;span class="na"&gt;tenant-id=&lt;/span&gt;&lt;span class="s"&gt;"{{TenantId}}"&lt;/span&gt; &lt;span class="na"&gt;output-token-variable-name=&lt;/span&gt;&lt;span class="s"&gt;"caller"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;audiences&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;audience&amp;gt;&lt;/span&gt;{{OrdersPublicAudience}}&lt;span class="nt"&gt;&amp;lt;/audience&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/audiences&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/validate-azure-ad-token&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;rate-limit-by-key&lt;/span&gt; &lt;span class="na"&gt;calls=&lt;/span&gt;&lt;span class="s"&gt;"120"&lt;/span&gt; &lt;span class="na"&gt;renewal-period=&lt;/span&gt;&lt;span class="s"&gt;"60"&lt;/span&gt;
                       &lt;span class="na"&gt;counter-key=&lt;/span&gt;&lt;span class="s"&gt;"@("&lt;/span&gt;&lt;span class="err"&gt;orders-"&lt;/span&gt; &lt;span class="err"&gt;+&lt;/span&gt; &lt;span class="err"&gt;((Jwt)context.Variables["caller"]).Subject)"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;set-header&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"X-End-User-Id"&lt;/span&gt; &lt;span class="na"&gt;exists-action=&lt;/span&gt;&lt;span class="s"&gt;"override"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;value&amp;gt;&lt;/span&gt;@(((Jwt)context.Variables["caller"]).Claims.GetValueOrDefault("oid", ""))&lt;span class="nt"&gt;&amp;lt;/value&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/set-header&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;set-backend-service&lt;/span&gt; &lt;span class="na"&gt;backend-id=&lt;/span&gt;&lt;span class="s"&gt;"orders-api"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;set-header&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Ocp-Apim-Subscription-Key"&lt;/span&gt; &lt;span class="na"&gt;exists-action=&lt;/span&gt;&lt;span class="s"&gt;"delete"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;authentication-managed-identity&lt;/span&gt; &lt;span class="na"&gt;resource=&lt;/span&gt;&lt;span class="s"&gt;"{{OrdersApiAppId}}"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/inbound&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;backend&amp;gt;&amp;lt;base&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/backend&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;outbound&amp;gt;&amp;lt;base&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/outbound&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;on-error&amp;gt;&amp;lt;base&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/on-error&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/policies&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;check-header&lt;/code&gt; goes first&lt;/strong&gt;, so nothing downstream spends a token validation on a request that did not come through your profile. Front Door adds &lt;code&gt;X-Azure-FDID&lt;/code&gt; to every request it forwards, and anyone with portal access can read the value, so it proves only that a request passed through your profile. That still matters, because Front Door's address ranges are shared: &lt;a href="https://learn.microsoft.com/azure/frontdoor/origin-security" rel="noopener noreferrer"&gt;"IP address filtering alone isn't sufficient ... because other Azure customers use the same IP addresses"&lt;/a&gt;, and someone else's profile pointed at your gateway passes an IP filter and fails this check. Keep it once Private Link is in place, as the backstop for the day public network access is switched back on. &lt;a href="https://learn.microsoft.com/azure/api-management/check-header-policy" rel="noopener noreferrer"&gt;&lt;code&gt;ignore-case&lt;/code&gt; is required&lt;/a&gt; on this policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;validate-azure-ad-token&lt;/code&gt; is the reason the Front Door hop uses Private Link.&lt;/strong&gt; Front Door can authenticate to an origin with a managed identity, but it &lt;a href="https://learn.microsoft.com/azure/frontdoor/origin-authentication-with-managed-identities" rel="noopener noreferrer"&gt;"overwrites an existing &lt;code&gt;Authorization&lt;/code&gt; header with its origin authentication token"&lt;/a&gt;, so the caller's token would never reach this line unless a Front Door rule copied it into a different header first. The feature is still &lt;a href="https://learn.microsoft.com/azure/frontdoor/secure-front-door#identity-and-access-management" rel="noopener noreferrer"&gt;in preview&lt;/a&gt;: its &lt;code&gt;authentication&lt;/code&gt; block exists only in a preview API version of the origin group, which also adds a &lt;code&gt;tokenDestinationHeader&lt;/code&gt; that can move the token out of &lt;code&gt;Authorization&lt;/code&gt;. Neither helps here, because the feature "isn't currently supported for origins with Private Link enabled". &lt;code&gt;output-token-variable-name&lt;/code&gt; keeps the validated token as a &lt;a href="https://learn.microsoft.com/azure/api-management/api-management-policy-expressions" rel="noopener noreferrer"&gt;&lt;code&gt;Jwt&lt;/code&gt; object&lt;/a&gt; for the elements after it.&lt;/p&gt;

&lt;p&gt;Microsoft's &lt;a href="https://learn.microsoft.com/azure/api-management/rate-limit-by-key-policy" rel="noopener noreferrer"&gt;example for &lt;code&gt;rate-limit-by-key&lt;/code&gt;&lt;/a&gt; keys on &lt;code&gt;context.Request.IpAddress&lt;/code&gt;, which is the wrong key here. Behind Front Door, the connection API Management sees comes from Front Door, so an IP key throttles Front Door itself. The policy above keys on the validated subject instead, and the per-client-IP limit stays where it already works, in the WAF. Counters are tracked per gateway ("It doesn't aggregate call data across the entire instance") and shared by every scope that uses the same key, hence the &lt;code&gt;orders-&lt;/code&gt; prefix. v2 tiers count with a token bucket where classic tiers use a sliding window, and Microsoft warns that inconsistent limits on a shared key there "can cause unpredictable behavior", so give every policy that shares a key the same numbers.&lt;/p&gt;

&lt;p&gt;The last element, &lt;code&gt;authentication-managed-identity&lt;/code&gt;, is the reason the two &lt;code&gt;set-header&lt;/code&gt; elements above it are there at all. It requests a token for the &lt;code&gt;orders-api&lt;/code&gt; app registration and writes it into &lt;code&gt;Authorization&lt;/code&gt;, over the top of the caller's own token, so the end user's identity reaches &lt;code&gt;orders-api&lt;/code&gt; only if you copy it out first: here as the &lt;code&gt;oid&lt;/code&gt; claim in &lt;code&gt;X-End-User-Id&lt;/code&gt;, with &lt;code&gt;exists-action="override"&lt;/code&gt; so that a client sending its own &lt;code&gt;X-End-User-Id&lt;/code&gt; gets it replaced rather than trusted. The subscription key goes in the same pass, because by default it is &lt;a href="https://learn.microsoft.com/azure/api-management/api-management-subscriptions" rel="noopener noreferrer"&gt;"passed to the backend and might be exposed in backend monitoring logs"&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The gateway then &lt;a href="https://learn.microsoft.com/azure/api-management/authentication-managed-identity-policy" rel="noopener noreferrer"&gt;caches that token until it expires&lt;/a&gt;, and the element "doesn't validate which backend the token is sent to". Anyone who can write an API policy can point this element at a resource of their choosing and collect the gateway identity's token, so treat policy-edit rights as token rights.&lt;/p&gt;

&lt;h3&gt;
  
  
  Subscription keys, per product
&lt;/h3&gt;

&lt;p&gt;A partner's key is issued against a product that requires a subscription, and the API it contains is the one the policy above is attached to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resource ordersHttpApi 'Microsoft.ApiManagement/service/apis@2024-05-01' = {
  parent: apim
  name: 'orders'
  properties: {
    displayName: 'Orders'
    path: 'api/orders'
    protocols: [ 'https' ]
    subscriptionRequired: true
  }
}

resource partnerProduct 'Microsoft.ApiManagement/service/products@2024-05-01' = {
  parent: apim
  name: 'orders-partners'
  properties: {
    displayName: 'Orders for partners'
    subscriptionRequired: true
    state: 'published'
  }
}

resource partnerProductOrders 'Microsoft.ApiManagement/service/products/apis@2024-05-01' = {
  parent: partnerProduct
  name: ordersHttpApi.name
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A subscription key tells you which product a caller bought, and it is a shared secret with no lifecycle: API Management &lt;a href="https://learn.microsoft.com/azure/api-management/api-management-subscriptions" rel="noopener noreferrer"&gt;"doesn't provide built-in features to manage the lifecycle of subscription keys, such as setting expiration dates or automatically rotating keys"&lt;/a&gt;. That is why the policy validates a token as well, and because &lt;code&gt;tenant-id&lt;/code&gt; pins the issuer to your tenant, a partner needs a token your tenant issues: from an app registration you create for them, or from their multitenant app once it is consented into your tenant.&lt;/p&gt;

&lt;p&gt;The same page describes the configuration that quietly disables every key you have issued. "If the key isn't valid but a product exists that includes the API without requiring a subscription (an &lt;strong&gt;open product&lt;/strong&gt;), API Management ignores the key and handles the request as an API request without a subscription key." One open product left over from a demo turns a revoked partner key into anonymous access. Scope is the other trap: requests made with an API-scoped, all-APIs or built-in all-access subscription skip product-scope policies entirely. Keep the checks in the policy above at API scope, and use product scope for what legitimately differs per product, such as &lt;code&gt;rate-limit&lt;/code&gt; and &lt;code&gt;quota&lt;/code&gt;, which only apply when a subscription key is used.&lt;/p&gt;

&lt;h3&gt;
  
  
  Managed identity to Container Apps, and who else gets a token
&lt;/h3&gt;

&lt;p&gt;Every check so far runs in the gateway, which holds up until a caller skips it.&lt;/p&gt;

&lt;p&gt;Container Apps authentication, the &lt;strong&gt;Easy Auth&lt;/strong&gt; sidecar, validates the token that the last policy element attaches, and on its own it accepts far more than the gateway. Microsoft's page for &lt;a href="https://learn.microsoft.com/azure/container-apps/authentication-entra" rel="noopener noreferrer"&gt;this exact flow&lt;/a&gt; says it "allows &lt;em&gt;any&lt;/em&gt; client application in your Microsoft Entra tenant to request an access token and authenticate to the target app." Any workload in your tenant with a managed identity could call &lt;code&gt;orders-api&lt;/code&gt; directly, set &lt;code&gt;X-End-User-Id&lt;/code&gt; to any user's object ID, and look like API Management vouching for that user.&lt;/p&gt;

&lt;p&gt;That is worth a second read, because it inverts the usual reading of this diagram. The hop that gets pointed at when someone calls this architecture secure is the hop where nothing about the caller is checked beyond which tenant it lives in.&lt;/p&gt;

&lt;p&gt;Close it in Entra. Define an &lt;strong&gt;app role&lt;/strong&gt;, assign it to the gateway's identity, and set &lt;strong&gt;assignment required&lt;/strong&gt; on the &lt;code&gt;orders-api&lt;/code&gt; service principal. The client credentials documentation says that setting &lt;a href="https://learn.microsoft.com/entra/identity-platform/v2-oauth2-client-creds-grant-flow#get-direct-authorization" rel="noopener noreferrer"&gt;"will block users and applications without assigned roles from being able to get a token for this application"&lt;/a&gt;, so a workload without the role never gets as far as the sidecar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az ad app update &lt;span class="nt"&gt;--id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORDERS_API_APP_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--set&lt;/span&gt; api.requestedAccessTokenVersion&lt;span class="o"&gt;=&lt;/span&gt;2
az ad app update &lt;span class="nt"&gt;--id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORDERS_API_APP_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--app-roles&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'[{"allowedMemberTypes":["Application"],"value":"Orders.Gateway","displayName":"Orders gateway","description":"API Management calls orders-api","isEnabled":true}]'&lt;/span&gt;
&lt;span class="nv"&gt;GATEWAY_ROLE_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az ad app show &lt;span class="nt"&gt;--id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORDERS_API_APP_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"appRoles[?value=='Orders.Gateway'].id"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Role assignments and the assignment requirement live on the service principal, which may not exist yet.&lt;/span&gt;
&lt;span class="nv"&gt;ORDERS_SP_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az ad sp show &lt;span class="nt"&gt;--id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORDERS_API_APP_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; az ad sp create &lt;span class="nt"&gt;--id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORDERS_API_APP_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
az ad sp update &lt;span class="nt"&gt;--id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORDERS_SP_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;appRoleAssignmentRequired&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# The Azure CLI has no command for assigning an app role to a managed identity.&lt;/span&gt;
&lt;span class="nv"&gt;APIM_PRINCIPAL_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az resource show &lt;span class="nt"&gt;--ids&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$APIM_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; identity.principalId &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
az rest &lt;span class="nt"&gt;-m&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"https://graph.microsoft.com/v1.0/servicePrincipals/&lt;/span&gt;&lt;span class="nv"&gt;$APIM_PRINCIPAL_ID&lt;/span&gt;&lt;span class="s2"&gt;/appRoleAssignments"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;principalId&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$APIM_PRINCIPAL_ID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;resourceId&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$ORDERS_SP_ID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;appRoleId&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$GATEWAY_ROLE_ID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;

az containerapp auth microsoft update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; rg-orders &lt;span class="nt"&gt;--name&lt;/span&gt; orders-api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORDERS_API_APP_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;--issuer&lt;/span&gt; &lt;span class="s2"&gt;"https://login.microsoftonline.com/&lt;/span&gt;&lt;span class="nv"&gt;$TENANT_ID&lt;/span&gt;&lt;span class="s2"&gt;/v2.0"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--yes&lt;/span&gt;
az containerapp auth update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; rg-orders &lt;span class="nt"&gt;--name&lt;/span&gt; orders-api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enabled&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;--unauthenticated-client-action&lt;/span&gt; Return401
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first command exists because of the issuer line. The token version API Management receives is chosen by the &lt;code&gt;orders-api&lt;/code&gt; registration: a &lt;code&gt;requestedAccessTokenVersion&lt;/code&gt; of &lt;a href="https://learn.microsoft.com/entra/identity-platform/access-tokens#token-formats" rel="noopener noreferrer"&gt;&lt;code&gt;null&lt;/code&gt; or &lt;code&gt;1&lt;/code&gt; results in v1.0 tokens&lt;/a&gt;, and the manifest reference is explicit that &lt;a href="https://learn.microsoft.com/entra/identity-platform/reference-app-manifest#requestedaccesstokenversion-attribute" rel="noopener noreferrer"&gt;null is where a registration starts&lt;/a&gt;, since "if the value is null, this parameter defaults to 1, which corresponds to the v1.0 endpoint". A v1.0 token's issuer is &lt;code&gt;https://sts.windows.net/&amp;lt;tenant&amp;gt;/&lt;/code&gt;, which never matches the &lt;code&gt;/v2.0&lt;/code&gt; URL Microsoft tells you to configure. Set the registration to 2 and the two match by construction.&lt;/p&gt;

&lt;p&gt;Assignment required decides who gets a token. Any role on the registration satisfies it, and Easy Auth "doesn't perform the validation steps" for roles, so a role check in code is the second line, for the day someone gives another identity a different role on the same registration:&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;System.Text.Json&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;GatewayCaller&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;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;PrincipalClaim&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;Typ&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;Val&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;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ClientPrincipal&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;RoleTyp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PrincipalClaim&lt;/span&gt;&lt;span class="p"&gt;[]?&lt;/span&gt; &lt;span class="n"&gt;Claims&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="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;JsonSerializerOptions&lt;/span&gt; &lt;span class="n"&gt;Json&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;PropertyNamingPolicy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonNamingPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SnakeCaseLower&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;HasRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpRequest&lt;/span&gt; &lt;span class="n"&gt;request&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;role&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;header&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"X-MS-CLIENT-PRINCIPAL"&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;header&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;if&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="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ClientPrincipal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;Convert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromBase64String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Json&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Claims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RoleTyp&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;roleType&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;return&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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="n"&gt;Typ&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="s"&gt;"roles"&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="n"&gt;Typ&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;roleType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Val&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="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;"/api/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;AddEndpointFilter&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;context&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="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;GatewayCaller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HasRole&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="s"&gt;"Orders.Gateway"&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="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="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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;Status403Forbidden&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The identity headers come from the sidecar, and &lt;a href="https://learn.microsoft.com/azure/container-apps/authentication" rel="noopener noreferrer"&gt;"external requests aren't allowed to set these headers"&lt;/a&gt;. The Base64 &lt;code&gt;X-MS-CLIENT-PRINCIPAL&lt;/code&gt; header and its &lt;code&gt;role_typ&lt;/code&gt; field are &lt;a href="https://learn.microsoft.com/azure/app-service/configure-authentication-user-identities" rel="noopener noreferrer"&gt;documented for App Service's Easy Auth&lt;/a&gt;, whose claims "undergo a default claims-mapping process", which is why the filter accepts &lt;code&gt;roles&lt;/code&gt; or whatever &lt;code&gt;role_typ&lt;/code&gt; names. Role changes also arrive late. Managed identity back ends &lt;a href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/managed-identity-best-practice-recommendations#limitation-of-using-managed-identities-for-authorization" rel="noopener noreferrer"&gt;cache tokens per resource "for around 24 hours"&lt;/a&gt;, and API Management caches its own until expiry, so a token issued before the assignment can keep arriving without the &lt;code&gt;roles&lt;/code&gt; claim, and the filter answers 403 until it ages out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Container Apps: the layer that decides who gets in and how many answer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Which ingress settings let the gateway in
&lt;/h3&gt;

&lt;p&gt;Whether API Management can reach &lt;code&gt;orders-api&lt;/code&gt; at all depends on the environment's accessibility, fixed when the environment is created, and on the app's &lt;code&gt;external&lt;/code&gt; flag, which &lt;a href="https://learn.microsoft.com/azure/container-apps/ingress-how-to" rel="noopener noreferrer"&gt;you can change on any deploy&lt;/a&gt; without creating a revision.&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%2Fkr40kgvwgax8i6xhreqr.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%2Fkr40kgvwgax8i6xhreqr.png" alt="Container Apps ingress: environment accessibility against the app's external flag. An internal environment with external: false answers the rest of the VNet with a 404" width="800" height="134"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bottom-right cell is the Aspire 404 from the backend section, and the bottom-left cell is where &lt;code&gt;orders-api&lt;/code&gt; belongs. &lt;code&gt;inventory-api&lt;/code&gt; sits outside the table, because it has no ingress at all. Other Dapr-enabled apps &lt;a href="https://learn.microsoft.com/azure/container-apps/connect-apps#dapr-service-invocation" rel="noopener noreferrer"&gt;still invoke it through Dapr service invocation&lt;/a&gt;, which is the only way anything in this design calls it.&lt;/p&gt;

&lt;p&gt;This is &lt;code&gt;orders-api&lt;/code&gt; as the rest of the section configures it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;param blueSuffix string
param greenSuffix string = ''
param greenWeight int = 0
param apimIntegrationSubnetPrefix string

var latestSuffix = !empty(greenSuffix) ? greenSuffix : blueSuffix

resource ordersApp 'Microsoft.App/containerApps@2026-01-01' = {
  name: 'orders-api'
  location: location
  identity: { type: 'SystemAssigned' }
  properties: {
    environmentId: acaEnvironment.id
    configuration: {
      activeRevisionsMode: 'Multiple'
      ingress: {
        external: true
        targetPort: 8080
        transport: 'auto'
        ipSecurityRestrictions: [
          {
            name: 'apim-outbound'
            ipAddressRange: apimIntegrationSubnetPrefix
            action: 'Allow'
          }
        ]
        traffic: !empty(blueSuffix) &amp;amp;&amp;amp; !empty(greenSuffix) ? [
          { revisionName: 'orders-api--${blueSuffix}', label: 'blue', weight: 100 - greenWeight }
          { revisionName: 'orders-api--${greenSuffix}', label: 'green', weight: greenWeight }
        ] : [
          { revisionName: 'orders-api--${blueSuffix}', label: 'blue', weight: 100 }
        ]
      }
      dapr: { enabled: true, appId: 'orders-api', appPort: 8080 }
    }
    template: {
      revisionSuffix: latestSuffix
      containers: [
        { name: 'orders-api', image: 'acrorders.azurecr.io/orders-api:${latestSuffix}' }
      ]
      scale: {
        minReplicas: 1
        maxReplicas: 20
        rules: [
          { name: 'http-rate', http: { metadata: { concurrentRequests: '40' } } }
        ]
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only caller with ingress access is API Management, leaving from its integration subnet, so the allow rule is that subnet's prefix; Front Door's addresses never reach this app. Rules take &lt;a href="https://learn.microsoft.com/azure/container-apps/ip-restrictions" rel="noopener noreferrer"&gt;CIDR ranges only, and all of one type&lt;/a&gt; ("You can't combine allow rules and deny rules"), and they cannot read a header, so the &lt;code&gt;X-Azure-FDID&lt;/code&gt; check stays in the gateway. If a browser client ever appears, CORS goes in API Management's &lt;code&gt;cors&lt;/code&gt; policy as the first inbound element, because during a preflight &lt;a href="https://learn.microsoft.com/azure/api-management/cors-policy" rel="noopener noreferrer"&gt;"only the &lt;code&gt;cors&lt;/code&gt; policy is evaluated on the &lt;code&gt;OPTIONS&lt;/code&gt; request"&lt;/a&gt; and the request never reaches the app.&lt;/p&gt;

&lt;h3&gt;
  
  
  The HTTP rule counts a rate, not concurrency
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;concurrentRequests: '40'&lt;/code&gt; reads as "add a replica when 40 requests are in flight". It means something else. &lt;a href="https://learn.microsoft.com/azure/container-apps/scale-app#http" rel="noopener noreferrer"&gt;"Every 15 seconds, the number of concurrent requests is calculated as the number of requests in the past 15 seconds divided by 15"&lt;/a&gt;: requests per second, averaged over a 15-second window, and Microsoft's own example describes its setting as "100 concurrent requests per second". The replica count then follows &lt;code&gt;desiredReplicas = ceil(currentMetricValue / targetMetricValue)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Request duration appears nowhere in that formula, so one number can be wrong in both directions. Take the rule above on paper. An order lookup answering in 25 ms at 400 requests per second holds about 10 requests in flight across the whole app, and the rule asks for ceil(400 / 40) = 10 replicas, each doing one thing at a time. A report export taking 4 seconds at 5 requests per second holds 20 requests open and gets ceil(5 / 40) = 1 replica. The first overspends, the second queues, and the replica chart for both looks like autoscaling working.&lt;/p&gt;

&lt;p&gt;Set the value from throughput. Load one replica until latency starts to climb, note the request rate it sustained, and set &lt;code&gt;concurrentRequests&lt;/code&gt; below that. If an app serves both shapes of request, the rule can be right for one of them only, which is an argument for moving the slow endpoint into its own app. &lt;code&gt;maxReplicas&lt;/code&gt; is the cost ceiling either way: the &lt;a href="https://learn.microsoft.com/azure/container-apps/scale-app" rel="noopener noreferrer"&gt;scale limits table&lt;/a&gt; puts its default at 10 with 1,000 configurable, and replicas arrive in a &lt;a href="https://learn.microsoft.com/azure/container-apps/scale-app#scale-behavior" rel="noopener noreferrer"&gt;scale up step&lt;/a&gt; of "1, 4, 8, 16, 32, ... up to configured maximum replicas".&lt;/p&gt;

&lt;p&gt;&lt;code&gt;minReplicas: 1&lt;/code&gt; matters more here than it did during the migration. Nothing sends &lt;code&gt;orders-api&lt;/code&gt; traffic on a schedule any more, because Front Door's probe stops at the gateway's status path, so at zero replicas the first call after a quiet spell waits for a replica to start while API Management holds the request open. &lt;code&gt;scale.cooldownPeriod&lt;/code&gt; does not soften that: &lt;a href="https://learn.microsoft.com/azure/container-apps/scale-app#custom" rel="noopener noreferrer"&gt;"The cool down period only takes effect when scaling in from the final replica to 0."&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Queue depth for the app nobody calls over HTTP
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;inventory-api&lt;/code&gt; scales on the backlog of the &lt;code&gt;orders&lt;/code&gt; topic instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resource inventoryApp 'Microsoft.App/containerApps@2026-01-01' = {
  name: 'inventory-api'
  location: location
  identity: { type: 'SystemAssigned' }
  properties: {
    environmentId: acaEnvironment.id
    configuration: {
      activeRevisionsMode: 'Single'
      dapr: { enabled: true, appId: 'inventory-api', appPort: 8080 }
    }
    template: {
      containers: [
        { name: 'inventory-api', image: 'acrorders.azurecr.io/inventory-api:${releaseSuffix}' }
      ]
      scale: {
        minReplicas: 1
        maxReplicas: 10
        rules: [
          {
            name: 'orders-backlog'
            custom: {
              type: 'azure-servicebus'
              identity: 'system'
              metadata: {
                namespace: serviceBusNamespaceName
                topicName: 'orders'
                subscriptionName: 'inventory-api'
                messageCount: '30'
              }
            }
          }
        ]
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;ingress&lt;/code&gt; block, and &lt;code&gt;activeRevisionsMode: 'Single'&lt;/code&gt; is deliberate: the scale rules page asks for single revision mode &lt;a href="https://learn.microsoft.com/azure/container-apps/scale-app" rel="noopener noreferrer"&gt;whenever a scale rule is not an HTTP one&lt;/a&gt;, which is one reason the canary lives on &lt;code&gt;orders-api&lt;/code&gt;. &lt;code&gt;messageCount&lt;/code&gt; is a target per replica, so &lt;a href="https://learn.microsoft.com/azure/container-apps/dapr-keda-scaling" rel="noopener noreferrer"&gt;150 waiting messages ask for five replicas&lt;/a&gt;, and &lt;code&gt;subscriptionName&lt;/code&gt; has to name the subscription the Dapr component drains, the &lt;a href="https://dev.to/martin_oehlert/azure-functions-to-container-apps-one-endpoint-at-a-time-2e1#consumerid-is-the-subscription-name-and-it-defaults-to-your-app-id"&gt;pairing Part 5 covered&lt;/a&gt;. Keep &lt;code&gt;namespace&lt;/code&gt; even though that Microsoft sample leaves it out: KEDA's Service Bus scaler &lt;a href="https://keda.sh/docs/latest/scalers/azure-service-bus/" rel="noopener noreferrer"&gt;requires it&lt;/a&gt; when it authenticates with an identity, and no Container Apps page says the platform fills it in.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;identity: 'system'&lt;/code&gt; &lt;a href="https://learn.microsoft.com/azure/container-apps/managed-identity#scale-rules" rel="noopener noreferrer"&gt;authenticates the rule&lt;/a&gt; with the app's own identity. Container Apps documents neither the Service Bus role that identity needs nor whether the scaler reaches a namespace with public access disabled. The nearest answers are both too generous: the AKS guide for KEDA &lt;a href="https://learn.microsoft.com/azure/aks/keda-workload-identity" rel="noopener noreferrer"&gt;assigns Azure Service Bus Data Owner&lt;/a&gt;, and KEDA's own reference wants a Manage policy for a connection string, either of them more than a consumer should hold. That is why &lt;code&gt;minReplicas&lt;/code&gt; is 1: a replica that exists keeps draining the subscription whether or not the scaler can read it, and it is also awake for Dapr invocations from &lt;code&gt;orders-api&lt;/code&gt;, which don't pass through ingress and so can't wake an app from zero.&lt;/p&gt;

&lt;h3&gt;
  
  
  Revisions: the rollout Front Door no longer does
&lt;/h3&gt;

&lt;p&gt;In Part 5, Front Door origin weights moved traffic between two runtimes. With one origin and one gateway, the rollout axis is the app's revisions, two layers below anything Front Door can see.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;traffic&lt;/code&gt; block follows &lt;a href="https://learn.microsoft.com/azure/container-apps/blue-green-deployment?pivots=bicep" rel="noopener noreferrer"&gt;Microsoft's blue-green template&lt;/a&gt;. The first deploy sets only &lt;code&gt;blueSuffix&lt;/code&gt;, and the single entry sends everything to the one revision that deploy creates. The next deploy sets &lt;code&gt;greenSuffix&lt;/code&gt;, creates the green revision and keeps it at &lt;code&gt;greenWeight: 0&lt;/code&gt;. &lt;strong&gt;Weights&lt;/strong&gt; split the requests that arrive on the app's own FQDN, which is the URL API Management's backend calls, so a deploy with &lt;code&gt;greenWeight: 10&lt;/code&gt; is a canary for every caller of the API with no gateway change. &lt;strong&gt;Labels&lt;/strong&gt; give a revision its own address, &lt;code&gt;orders-api---green.&amp;lt;default domain&amp;gt;&lt;/code&gt;, and a &lt;a href="https://learn.microsoft.com/azure/container-apps/revisions" rel="noopener noreferrer"&gt;label keeps its URL when you move it to another revision&lt;/a&gt;. The zone's &lt;code&gt;*&lt;/code&gt; record already resolves that name, but the smoke test still has to come from an address &lt;code&gt;ipSecurityRestrictions&lt;/code&gt; allows: add a test operation in API Management that targets the label address, or add your build agents' subnet to the rule for the duration of the rollout.&lt;/p&gt;

&lt;p&gt;Promotion follows the template too: &lt;code&gt;az containerapp ingress traffic set --label-weight blue=0 green=100&lt;/code&gt;, then a deploy with &lt;code&gt;greenWeight: 100&lt;/code&gt; so the next template run doesn't put the old split back. Rollback is the same with the numbers reversed. Neither creates a revision, because ingress changes &lt;a href="https://learn.microsoft.com/azure/container-apps/ingress-how-to" rel="noopener noreferrer"&gt;apply to every revision at once&lt;/a&gt;. Keeping both revisions active has a price: &lt;code&gt;minReplicas&lt;/code&gt; counts &lt;a href="https://learn.microsoft.com/azure/container-apps/scale-app" rel="noopener noreferrer"&gt;per revision&lt;/a&gt;, so each one holds its own warm replica, and sticky sessions are &lt;a href="https://learn.microsoft.com/azure/container-apps/sticky-sessions" rel="noopener noreferrer"&gt;single revision mode only&lt;/a&gt;, which Part 5 &lt;a href="https://dev.to/martin_oehlert/azure-functions-to-container-apps-one-endpoint-at-a-time-2e1#caching-and-affinity-change-what-a-cutover-means"&gt;ran into&lt;/a&gt; from the Front Door side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trust chain, hop by hop
&lt;/h2&gt;

&lt;p&gt;Here are the four hops from the introduction again, with what the receiving side of each one actually relies on:&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%2F8mpwbi4769z2ed6x2e84.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%2F8mpwbi4769z2ed6x2e84.png" alt="The trust chain hop by hop: what each receiver trusts, whether managed identity is involved, and what you have to add" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You settled the first two in the gateway section. From Front Door to API Management, trust rests on a network path and an identifier, and neither of those is a credential, which is why &lt;code&gt;check-header&lt;/code&gt; stays in the policy. From API Management to &lt;code&gt;orders-api&lt;/code&gt;, the assignment requirement decides which identities can get a token at all, and your role check confirms that the one which arrived carries the gateway's role.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sidecar to app: the token you no longer set
&lt;/h3&gt;

&lt;p&gt;Container Apps generates &lt;code&gt;APP_API_TOKEN&lt;/code&gt; for you. The platform &lt;a href="https://learn.microsoft.com/azure/container-apps/dapr-authentication-token" rel="noopener noreferrer"&gt;injects it into every Dapr-enabled app&lt;/a&gt;, "unique per each app and app revision", and the &lt;a href="https://learn.microsoft.com/azure/container-apps/enable-dapr" rel="noopener noreferrer"&gt;supported &lt;code&gt;dapr&lt;/code&gt; settings&lt;/a&gt; have no field for it or for &lt;code&gt;DAPR_API_TOKEN&lt;/code&gt;. Part 3's &lt;a href="https://dev.to/martin_oehlert/building-your-first-dapr-web-api-service-3p2g#the-token-nobody-checks"&gt;endpoint filter&lt;/a&gt; still does the checking, and its fail-open branch never runs in Azure: the variable is always set when Dapr is enabled. The token "can also change at any time", so never copy it into Key Vault or another app's settings.&lt;/p&gt;

&lt;p&gt;Placement matters with two filters in play. The token filter goes on routes the sidecar calls: &lt;code&gt;inventory-api&lt;/code&gt;'s invoked methods and its pub/sub handler. It never goes on the &lt;code&gt;orders-api&lt;/code&gt; routes API Management calls, because those requests carry no &lt;code&gt;dapr-api-token&lt;/code&gt; and every one of them would get a 401. The &lt;code&gt;GatewayCaller&lt;/code&gt; role check is the mirror image and belongs only on those gateway routes.&lt;/p&gt;

&lt;p&gt;The token proves that a call came through &lt;code&gt;inventory-api&lt;/code&gt;'s own sidecar. It does not say which app sent it. The sidecars &lt;a href="https://learn.microsoft.com/azure/container-apps/connect-apps#dapr-service-invocation" rel="noopener noreferrer"&gt;secure that call with mutual TLS on their own&lt;/a&gt;, but the Dapr access control policies that would restrict which app IDs may invoke &lt;code&gt;inventory-api&lt;/code&gt; live in the Configuration spec, which &lt;a href="https://dev.to/martin_oehlert/introduction-to-dapr-for-azure-developers-1l9h#what-azure-container-apps-gives-you-and-what-it-takes-away"&gt;Container Apps does not support&lt;/a&gt;. Any Dapr-enabled app in the environment can call it, so on this hop the boundary is who is allowed to deploy into the environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  App to data, and the network under all four hops
&lt;/h3&gt;

&lt;p&gt;On the last hop managed identity carries the trust with no secret anywhere, and what you add is plumbing. Both apps reach Service Bus and Cosmos DB with their own identities and data-plane role assignments, wired as in &lt;a href="https://dev.to/martin_oehlert/introduction-to-dapr-for-azure-developers-1l9h#the-auth-chain-that-hides-a-misconfiguration"&gt;Part 2&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The VNet in the diagram carries four subnets with one owner each: API Management's inbound private endpoint, its /27 integration subnet, the environment's /27, and &lt;code&gt;snet-pe&lt;/code&gt; for the data services. With public network access disabled on both services, each app resolves them through &lt;a href="https://learn.microsoft.com/azure/private-link/private-endpoint-dns" rel="noopener noreferrer"&gt;private DNS zones&lt;/a&gt;, &lt;code&gt;privatelink.servicebus.windows.net&lt;/code&gt; and &lt;code&gt;privatelink.documents.azure.com&lt;/code&gt;, linked to the same VNet as the environment's own zone. Miss a link and the name &lt;a href="https://learn.microsoft.com/azure/service-bus-messaging/private-link-service#troubleshoot-private-endpoint-connectivity" rel="noopener noreferrer"&gt;resolves to a public IP instead of the private one&lt;/a&gt;, so the failure shows up as a refused or timed-out connection from the app, with no DNS error anywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify on first deploy
&lt;/h3&gt;

&lt;p&gt;Each item below rests on documentation that describes the pieces separately and never shows them working together, so check it once in your own subscription:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Front Door origin reports healthy&lt;/strong&gt; once the private endpoint connection is approved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A managed identity without the app role is refused a token&lt;/strong&gt; for &lt;code&gt;orders-api&lt;/code&gt;. The assignment requirement is documented for applications in general, never for a managed identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One decoded &lt;code&gt;X-MS-CLIENT-PRINCIPAL&lt;/code&gt;&lt;/strong&gt; from a real gateway call tells you the claim type the role arrives under.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A call to &lt;code&gt;orders-api&lt;/code&gt; from a VM in another subnet is refused.&lt;/strong&gt; That is the ingress proxy comparing the source address you expect it to compare.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;inventory-api&lt;/code&gt; replica count follows a test backlog&lt;/strong&gt; with public network access disabled on the namespace, which proves the scaler reaches Service Bus with the role you granted.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;This architecture gets sold on one line: each layer authenticates to the next with managed identity. On the data hop that line is true. On the hop from API Management to &lt;code&gt;orders-api&lt;/code&gt; it is true only after Entra refuses tokens to identities without the app role, and false every minute before that. The other two hops never claimed it in the first place. Front Door to API Management runs on a network path and an identifier, and sidecar to app runs on a token the platform generates for you.&lt;/p&gt;

&lt;p&gt;It is still a sound design, as long as the checks carrying the trust that managed identity does not carry are actually deployed. The expensive mistake is dropping the header check or the assignment requirement because "it's managed identity". The other price is on the invoice: no public origin means a Premium Front Door profile, a v2 gateway and a Premium namespace, all billed before the first container starts, so price them for your region before the design review rather than after it.&lt;/p&gt;

&lt;p&gt;Part 7 puts this stack into Terraform, where the steps this article ran by hand (the private endpoint approval, the app role, the assignment requirement) have to become code or be forgotten in the next environment.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Have you set &lt;strong&gt;assignment required&lt;/strong&gt; on your API's service principal, or are you relying on the role check in &lt;strong&gt;app code&lt;/strong&gt;?&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>azure</category>
      <category>containerapps</category>
      <category>architecture</category>
      <category>security</category>
    </item>
    <item>
      <title>Azure Functions to Container Apps, One Endpoint at a Time</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 11 Sep 2026 06:06:01 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/azure-functions-to-container-apps-one-endpoint-at-a-time-2e1</link>
      <guid>https://dev.to/martin_oehlert/azure-functions-to-container-apps-one-endpoint-at-a-time-2e1</guid>
      <description>&lt;p&gt;You have &lt;code&gt;/api/orders/*&lt;/code&gt; running on a Dapr-enabled Container App in staging and &lt;code&gt;/api/reports/*&lt;/code&gt; nowhere near ready, and Azure Front Door will move production traffic for you either way. The two ways are different objects and you cannot get one from the other: a &lt;strong&gt;route&lt;/strong&gt; maps a set of path patterns to exactly one &lt;strong&gt;origin group&lt;/strong&gt;, while a &lt;strong&gt;weight&lt;/strong&gt; splits traffic between origins &lt;em&gt;inside&lt;/em&gt; a single origin group. Take the route and the cutover happens one path at a time, reverting by deleting a pattern. Take the weight and both runtimes have to serve every path on that route, and the ratio you typed does nothing at all until you change a load balancing setting almost nobody mentions. The four phases below are the order those two levers go in, and what each one costs you when it goes wrong.&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%2Ftggmjyckko6kwplz7j2j.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%2Ftggmjyckko6kwplz7j2j.png" alt="The four phases and what moves in each" width="712" height="948"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: deploy alongside and route nothing
&lt;/h2&gt;

&lt;p&gt;The running scenario for the rest of this article: an order API on &lt;code&gt;api.contoso.com&lt;/code&gt;, fronted by a Front Door Standard profile, with a single route whose patterns are &lt;code&gt;['/*']&lt;/code&gt; pointing at a Function App origin. Everything is on that one route today. &lt;code&gt;/api/orders/*&lt;/code&gt; moves to the Container App first, &lt;code&gt;/api/reports/*&lt;/code&gt; moves last.&lt;/p&gt;

&lt;p&gt;Phase 1 adds two things and changes nothing else. The Container App gets deployed and registered in the Front Door profile as an origin in its own origin group. No route points at that group yet, so it serves zero production requests, and the only claim phase 1 makes is that Front Door can reach it and gets a 200 back from a probe.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/*&lt;/code&gt; route stays exactly where it is, for the whole migration. It is the &lt;strong&gt;catch-all&lt;/strong&gt;: the fallback for every endpoint you have not moved yet, and the thing that catches a path back when you revert one. Front Door has no implicit default, and a request matching no route errors out with one of two status codes depending on which doc you read, so do not build anything that inspects the code.&lt;/p&gt;

&lt;p&gt;Everything here is Front Door Standard/Premium, resource type &lt;code&gt;Microsoft.Cdn/profiles&lt;/code&gt;. Front Door classic (&lt;code&gt;Microsoft.Network/frontDoors&lt;/code&gt;) &lt;a href="https://learn.microsoft.com/azure/frontdoor/tier-migration" rel="noopener noreferrer"&gt;retires on 2027-03-31&lt;/a&gt;: no new profiles, no new domain onboarding, no new managed certificates. If your existing profile is classic, the migration in front of you is a different one from this article's.&lt;/p&gt;

&lt;p&gt;The ordering decision worth making now is which path goes last, and &lt;code&gt;/api/reports/*&lt;/code&gt; is last here for a reason that is not "reports matter less". Front Door's &lt;a href="https://learn.microsoft.com/azure/frontdoor/how-to-configure-origin#origin-response-timeout" rel="noopener noreferrer"&gt;origin response timeout&lt;/a&gt; is 16 to 240 seconds, and "this timeout value is applied to all endpoints in the Azure Front Door profile". A long-running report path cannot be given a longer timeout than the order path next to it. Whichever endpoint has the widest latency spread sets the number for the whole profile, so you move it when you have the most information.&lt;/p&gt;

&lt;h3&gt;
  
  
  The probe is the first thing that breaks
&lt;/h3&gt;

&lt;p&gt;Front Door's health probe has a rule that reads as a detail and behaves as an outage: &lt;a href="https://learn.microsoft.com/azure/frontdoor/health-probes" rel="noopener noreferrer"&gt;only 200 OK counts as healthy&lt;/a&gt;. Not 204, not 301. Put a Function App whose &lt;code&gt;/healthz&lt;/code&gt; returns 204, which plenty do on purpose, in an origin group next to a container that returns 200, and you have manufactured an outage in the component you added to prevent one.&lt;/p&gt;

&lt;p&gt;The second rule contradicts the first place you would look: the probe path is case sensitive, while route path patterns are case insensitive, to the point that Front Door rejects &lt;code&gt;/FOO&lt;/code&gt; and &lt;code&gt;/foo&lt;/code&gt; as duplicates in the same field.&lt;/p&gt;

&lt;p&gt;The Container Apps side is quieter and worse. With ingress enabled and no probes defined, Container Apps &lt;a href="https://learn.microsoft.com/azure/container-apps/health-probes#default-configuration" rel="noopener noreferrer"&gt;adds defaults&lt;/a&gt; that are TCP checks against the ingress target port, and a TCP probe passes as long as something is bound to it. A process that is listening and broken, whose database connection died on startup or whose configuration failed to bind, passes every default probe it gets: false green, at the exact moment you are looking for evidence that the new runtime works.&lt;/p&gt;

&lt;p&gt;On top of startup, readiness and liveness, a Dapr-enabled app adds a fourth opinion: the sidecar's &lt;a href="https://learn.microsoft.com/azure/container-apps/dapr-overview" rel="noopener noreferrer"&gt;own app health probe&lt;/a&gt; (&lt;code&gt;dapr.appHealth&lt;/code&gt;, with its own path, interval, timeout and threshold), which decides whether the sidecar considers your app up. That is a separate question from whether the platform considers the replica up, and from whether Front Door considers the origin healthy.&lt;/p&gt;

&lt;p&gt;Collapse it onto one answer: one HTTP health endpoint returning exactly 200, the same path with the same casing on both origins, and explicit probes replacing the TCP defaults so the container's readiness reflects the check Front Door is making:&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;probes&lt;/span&gt;&lt;span class="pi"&gt;:&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;Readiness&lt;/span&gt;
    &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/healthz&lt;/span&gt;      &lt;span class="c1"&gt;# same string, same casing, as the Front Door probePath&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then have the Function App serve &lt;code&gt;/healthz&lt;/code&gt; at 200 too, before you point a single probe at it, and check which verb you are probing with while you are there. New profiles default to &lt;code&gt;HEAD&lt;/code&gt;, which is the right choice for load, and a &lt;code&gt;MapGet&lt;/code&gt; answers it with 405 while an &lt;code&gt;[HttpTrigger(..., "get")]&lt;/code&gt; answers it with 404. Either add the verb on both origins or set &lt;code&gt;probeRequestType: 'GET'&lt;/code&gt;, because the rule at the top of this aside does not care why the response was not a 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: send the new endpoints and nothing else
&lt;/h2&gt;

&lt;p&gt;Phase 2 is the first config that carries traffic, and it is two resources: an origin group for the orders path, and a route pointing at it. Everything else in the profile is untouched.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resource ordersOriginGroup 'Microsoft.Cdn/profiles/originGroups@2021-06-01' = {
  name: 'orders-origin-group'
  parent: frontDoorProfile
  properties: {
    loadBalancingSettings: {
      sampleSize: 4
      successfulSamplesRequired: 3
      // Load-bearing, and it does nothing in phase 2. Leave it at the default 0 and the
      // weights you set in phase 3 are ignored entirely. See "Your 90/10 split is a
      // 100/0 split" below.
      additionalLatencyInMilliseconds: 500
    }
    healthProbeSettings: {
      probePath: '/healthz'          // case sensitive, unlike patternsToMatch
      probeRequestType: 'HEAD'
      probeProtocol: 'Https'
      probeIntervalInSeconds: 30     // set it; the documented default varies by source
    }
  }
}

resource ordersRoute 'Microsoft.Cdn/profiles/afdEndpoints/routes@2021-06-01' = {
  name: 'orders-route'
  parent: frontDoorEndpoint
  dependsOn: [ ordersOrigin ]   // the origin group must not be empty when the route is created
  properties: {
    originGroup: { id: ordersOriginGroup.id }
    supportedProtocols: [ 'Https' ]
    // Both patterns, deliberately. The wildcard rules do not settle whether
    // '/api/orders/*' also matches a bare '/api/orders', and POST /api/orders is the
    // create-order call. Listing both is correct either way and costs nothing.
    patternsToMatch: [ '/api/orders', '/api/orders/*' ]
    forwardingProtocol: 'HttpsOnly'
    linkToDefaultDomain: 'Enabled'
    httpsRedirect: 'Enabled'
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;/*&lt;/code&gt; route from phase 1 is still deployed and still points at the Function App, and nothing about this new route mentions it. That omission is why the strangler fig shape works in Front Door: there is no priority field on a route and declaration order is irrelevant. Front Door "always matches to the most-specific request by evaluating the left-hand side properties: protocol, domain, and path, in that order", with the frontend host matched exactly and the path matched against exact patterns first, wildcard patterns second. &lt;code&gt;/api/orders/12345&lt;/code&gt; hits &lt;code&gt;orders-route&lt;/code&gt; and &lt;code&gt;/api/reports/monthly&lt;/code&gt; falls through to the catch-all, and you did not order anything to make that happen.&lt;/p&gt;

&lt;p&gt;The wildcard syntax has three rules and they are all about the end of the string: &lt;code&gt;*&lt;/code&gt; is valid only as the last character, with nothing after it, preceded by &lt;code&gt;/&lt;/code&gt;. A pattern with no wildcard is an exact match, and so is a pattern ending in &lt;code&gt;/&lt;/code&gt;, which is the one that catches people:&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%2Fc0u0lsulj3k69xknr3gs.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%2Fc0u0lsulj3k69xknr3gs.png" alt="How Front Door matches a request against route patterns, most specific first" width="800" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The CLI equivalent, if your Front Door is not in Bicep yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az afd route create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; rg-orders &lt;span class="nt"&gt;--profile-name&lt;/span&gt; afd-orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--endpoint-name&lt;/span&gt; orders-endpoint &lt;span class="nt"&gt;--route-name&lt;/span&gt; orders-route &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--origin-group&lt;/span&gt; orders-origin-group &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--patterns-to-match&lt;/span&gt; &lt;span class="s2"&gt;"[/api/orders,/api/orders/*]"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--supported-protocols&lt;/span&gt; &lt;span class="s2"&gt;"[Https]"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--forwarding-protocol&lt;/span&gt; HttpsOnly &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--https-redirect&lt;/span&gt; Enabled &lt;span class="nt"&gt;--link-to-default-domain&lt;/span&gt; Enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;--cache-configuration&lt;/code&gt; flag on that command, and its absence is the setting: omitting it leaves caching off for the route. A migrating order API wants it off badly enough that phase 3 spends an aside on what happens if you forget.&lt;/p&gt;

&lt;p&gt;Doing this one path at a time has a ceiling, and it is far away: a Standard profile allows 100 routes and 100 origin groups (Premium doubles both), 100 path patterns per route, and a composite limit of 5000 routes per profile counted as domains multiplied by paths. An API with thirty endpoints is not close to any of those.&lt;/p&gt;

&lt;h3&gt;
  
  
  A route is a switch, not a dial
&lt;/h3&gt;

&lt;p&gt;Read the route above as a percentage control and you will misread the whole migration. A route maps its patterns to exactly one origin group and 100% of matching requests go there. There is no weight, ratio or percentage anywhere in a route's properties, and no partial state between "the Function App serves &lt;code&gt;/api/orders&lt;/code&gt;" and "the Container App serves it".&lt;/p&gt;

&lt;p&gt;The percentage lever lives one level down: &lt;strong&gt;origin weights&lt;/strong&gt;, integers from 1 to 1000, on the origins inside a single origin group. That is a different object with different behaviour, and the two do not compose the way most migration write-ups imply. You cannot put 10% of &lt;code&gt;/api/orders/*&lt;/code&gt; on the Container App by editing a route, because what the route selects is the group, not the origin. Which leaves a design fork that decides the rest of your migration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two origin groups plus two routes.&lt;/strong&gt; Per-path cutover, no percentage available. You revert by removing a pattern from &lt;code&gt;patternsToMatch&lt;/code&gt;, and the catch-all route re-catches the path. Blast radius is one path pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One origin group plus two origins.&lt;/strong&gt; Percentage rollout by weight, reverted by disabling the new origin. The cost is that both origins are eligible for every path on that route, so the Function App and the Container App each have to serve the whole pattern set.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Phase 2 is the first shape, deliberately: a path you have never sent a request to is the cheapest thing in the world to move, and the revert is one line of config. Phase 3 is the second shape, where the weights come out and turn out not to work at their defaults.&lt;/p&gt;

&lt;h3&gt;
  
  
  The host header decides which app answers
&lt;/h3&gt;

&lt;p&gt;Both origins need one property that has no obvious symptom when it is missing, because Front Door is documented to fill it in for you: "If you use Azure Resource Manager templates or another method without explicitly setting this field, Front Door sends the incoming host name as the value for the host header." Omit &lt;code&gt;originHostHeader&lt;/code&gt; in Bicep and the Container App receives &lt;code&gt;Host: api.contoso.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That breaks on both sides, and harder on the new one. App Service and Functions want the host header to match the backend domain unless the custom domain is bound to the app. Container Apps goes further: the environment's ingress proxy routes traffic to the correct app, revision and replica &lt;em&gt;based on host headers&lt;/em&gt;, so a wrong header is not a mismatched certificate, it is the environment being unable to work out which of your apps you meant. Set it explicitly on each origin, and keep both lines next to each other so the asymmetry is obvious in review:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;originHostHeader: 'orders-api.orangeplant-77e5875b.westeurope.azurecontainerapps.io'  // Container App
originHostHeader: 'orders-func.azurewebsites.net'                                     // Function App
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same property decides which app answers a probe, so an unset host header can turn phase 1's green probe into a phase 2 outage without either config changing in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both runtimes on one Service Bus topic
&lt;/h2&gt;

&lt;p&gt;Phase 2 moved an HTTP path. The order topic is the first Azure resource both runtimes hold at once, and it is where a migration stops being a routing exercise. Three files decide whether that goes well, and the same string appears in all three.&lt;/p&gt;

&lt;p&gt;The Dapr component on the Container App:&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;# components/orders-pubsub.yaml&lt;/span&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;orders-pubsub&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="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="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;&amp;lt;user-assigned-identity-client-id&amp;gt;"&lt;/span&gt;
    &lt;span class="c1"&gt;# The line that decides whether this is a cutover or a duplicate.&lt;/span&gt;
    &lt;span class="c1"&gt;# consumerID is the Service Bus SUBSCRIPTION NAME, not a label.&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-fn"&lt;/span&gt;
    &lt;span class="c1"&gt;# The Function App's bicep already created the topic and the subscription.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Function App, unchanged, still deployed, still consuming:&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;ProcessOrder&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="s"&gt;"orders-fn"&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;"ServiceBus"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the scale rule on the Container App, which is the file people forget is part of the same decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scale: {
  minReplicas: 0
  maxReplicas: 10
  rules: [ { name: 'orders-topic', custom: {
    type: 'azure-servicebus'
    identity: 'system'
    metadata: {
      topicName: 'orders'
      subscriptionName: 'orders-fn'   // the same literal as consumerID above
      messageCount: '30'
    }
  } } ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;orders-fn&lt;/code&gt; three times, in three different formats, validated by nothing.&lt;/p&gt;

&lt;p&gt;That shared string buys you the second row of a two-row decision, and that decision is the whole coexistence story. A &lt;strong&gt;subscription&lt;/strong&gt; on a Service Bus topic "resembles a virtual queue that receives copies of the messages that are sent to the topic", and every subscription gets &lt;a href="https://learn.microsoft.com/azure/service-bus-messaging/service-bus-queues-topics-subscriptions#topics-and-subscriptions" rel="noopener noreferrer"&gt;its own copy&lt;/a&gt; of every published message. The number of subscriptions is the number of times each order gets processed.&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%2F5lnkepjmna9dx46mhf4i.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%2F5lnkepjmna9dx46mhf4i.png" alt="Two Service Bus topic topologies: separate subscriptions process every order twice, one shared subscription makes the runtimes competing consumers" width="800" height="187"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The second row is a supported configuration rather than a hack. Subscriptions "support the same patterns described earlier in this section regarding queues: competing consumer, temporal decoupling, load leveling, and load balancing." Dapr already does it to itself: replicas sharing an &lt;code&gt;app-id&lt;/code&gt; get each message delivered to &lt;a href="https://docs.dapr.io/developing-applications/building-blocks/pubsub/pubsub-overview/" rel="noopener noreferrer"&gt;"only one instance of that application"&lt;/a&gt;, so adding the Function widens an existing race rather than starting a new one. Which runtime wins any given race is not something you get to know, so do not write a handler whose correctness depends on which side processed order 41.&lt;/p&gt;

&lt;p&gt;One default changes load rather than behaviour, so nothing will page you about it. Dapr's &lt;code&gt;maxConcurrentHandlers&lt;/code&gt; defaults to &lt;code&gt;0&lt;/code&gt;, meaning unlimited; the Functions host's &lt;code&gt;maxConcurrentCalls&lt;/code&gt; defaults to &lt;code&gt;16&lt;/code&gt;. Point both runtimes at the same downstream database and the new side applies materially more concurrent pressure than the side it replaces, on a number nobody typed.&lt;/p&gt;

&lt;p&gt;Sessions are the one mismatch here that fails loudly, at component init. Session properties belong to the &lt;strong&gt;subscription metadata&lt;/strong&gt; rather than the component metadata (&lt;code&gt;requireSessions&lt;/code&gt;, default &lt;code&gt;false&lt;/code&gt;; &lt;code&gt;maxConcurrentSessions&lt;/code&gt;, default 8), and the Functions equivalent is &lt;code&gt;IsSessionsEnabled&lt;/code&gt;. Point a default Dapr subscriber at a session-enabled subscription the Function has been draining for two years and the component source returns &lt;code&gt;subscription %s already exists but session requirement doesn't match&lt;/code&gt;, with nothing in the portal mentioning sessions. If you enable sessions on the Dapr side, check the concurrency number too: 8 against the Functions host's 2000 is a drop of two and a half orders of magnitude.&lt;/p&gt;

&lt;h3&gt;
  
  
  consumerID is the subscription name, and it defaults to your app ID
&lt;/h3&gt;

&lt;p&gt;The Dapr reference page describes &lt;code&gt;consumerID&lt;/code&gt; as a grouping label. Verbatim: "Consumer ID (consumer tag) organizes one or more consumers into a group. Consumers with the same consumer ID work as one virtual consumer ... If the &lt;code&gt;consumerID&lt;/code&gt; is not provided, the Dapr runtime set it to the Dapr application ID (&lt;code&gt;appID&lt;/code&gt;) value."&lt;/p&gt;

&lt;p&gt;Read that as a label and you will leave it unset. The component source says what the page does not, which is that the value is a name of a real Azure entity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// pubsub/azure/servicebus/topics/servicebus.go&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;rErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReceiverForSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConsumerID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnsureSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subscribeCtx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConsumerID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ConsumerID&lt;/code&gt; goes straight into &lt;code&gt;NewReceiverForSubscription&lt;/code&gt; and &lt;code&gt;EnsureSubscription&lt;/code&gt; as the subscription argument. The consumer ID &lt;strong&gt;is&lt;/strong&gt; the Service Bus subscription name. That mapping comes from &lt;a href="https://github.com/dapr/components-contrib/blob/main/pubsub/azure/servicebus/topics/servicebus.go" rel="noopener noreferrer"&gt;components-contrib&lt;/a&gt;, not from the reference page, so cite the source if you have to defend it in review.&lt;/p&gt;

&lt;p&gt;Follow the default through the migration and you get a specific outcome. Deploy the Container App with app ID &lt;code&gt;orders-api&lt;/code&gt; and no &lt;code&gt;consumerID&lt;/code&gt;, and Dapr creates a Service Bus subscription literally named &lt;code&gt;orders-api&lt;/code&gt; and consumes from it. Nothing about &lt;code&gt;orders-fn&lt;/code&gt; changes. The Function keeps working perfectly. You are now in the first row of that table: every order processed twice, by two runtimes, with no error raised anywhere, and the only visible symptom is downstream. Duplicate confirmation emails, doubled inventory decrements, two rows where you expected one.&lt;/p&gt;

&lt;p&gt;The second half of the pairing is the scale rule. &lt;code&gt;subscriptionName&lt;/code&gt; in the KEDA &lt;code&gt;azure-servicebus&lt;/code&gt; rule is the entity KEDA polls to decide how many replicas you need. &lt;code&gt;consumerID&lt;/code&gt; is the entity Dapr drains. Both are naming the same kind of thing, so if the two strings differ, KEDA is measuring a backlog that the app it is scaling will never touch. The failure is quiet in the direction that matters: with &lt;code&gt;minReplicas: 0&lt;/code&gt;, KEDA watches a subscription that stays empty, keeps the app at zero replicas, and the subscription that is actually filling up has no consumer at all. Nothing validates the pair, in either resource.&lt;/p&gt;

&lt;p&gt;Entity management is the third thing the component does that a migration does not want it doing. &lt;code&gt;disableEntityManagement&lt;/code&gt; defaults to &lt;code&gt;"false"&lt;/code&gt;, meaning create-if-missing. The source shows what "automatically" costs: the component builds a second, administrative client, and &lt;code&gt;EnsureSubscription&lt;/code&gt; calls &lt;code&gt;EnsureTopic&lt;/code&gt; and then &lt;code&gt;GetSubscription&lt;/code&gt; before deciding whether to create anything. A failed &lt;code&gt;GetSubscription&lt;/code&gt; is an error rather than a shrug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adminClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetSubscription&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;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"could not get subscription %s: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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;GetSubscription&lt;/code&gt; is a management-plane call. A Container App whose managed identity holds only &lt;code&gt;Azure Service Bus Data Receiver&lt;/code&gt; has no path to the management plane, so on that reading subscription setup fails at init while the identity holds every right it needs to actually receive messages. The permission that looks correct on a least-privilege review is the one that stops the sidecar from starting. That chain is read off the source rather than watched, so treat the exact failure text as unconfirmed until you have the sidecar log in front of you.&lt;/p&gt;

&lt;p&gt;One related waste of time. Four fields in the component reference (&lt;code&gt;lockDurationInSec&lt;/code&gt;, &lt;code&gt;maxDeliveryCount&lt;/code&gt;, &lt;code&gt;defaultMessageTimeToLiveInSec&lt;/code&gt;, &lt;code&gt;autoDeleteOnIdleInSec&lt;/code&gt;) each carry the qualifier "Used during subscription creation only", and the source confirms there is no update path. In a migration you are always pointing at a subscription that already exists, so all four are inert: setting &lt;code&gt;maxDeliveryCount&lt;/code&gt; in your component YAML changes nothing about how many times a message is delivered. Set it on the Service Bus entity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do instead&lt;/strong&gt;, and it is the file at the top of this section. Set &lt;code&gt;consumerID&lt;/code&gt; explicitly to the Function's existing subscription name. Set &lt;code&gt;disableEntityManagement: "true"&lt;/code&gt;. Pre-create the topic and the subscription in the Function App's existing bicep, which already owns them. Make the KEDA &lt;code&gt;subscriptionName&lt;/code&gt; the same literal, and if your infrastructure is in Bicep, make it the same variable so the compiler enforces what neither service does. Then grep for that string before you deploy: it should appear in the component, the trigger attribute, and the scale rule, and nowhere else.&lt;/p&gt;

&lt;h3&gt;
  
  
  The envelope goes one way cleanly and one way silently
&lt;/h3&gt;

&lt;p&gt;Dapr "uses the CloudEvents 1.0 specification as its message format" and wraps outgoing messages &lt;a href="https://docs.dapr.io/developing-applications/building-blocks/pubsub/pubsub-cloudevents/" rel="noopener noreferrer"&gt;automatically&lt;/a&gt;. Publish an order from the Container App and this is what lands on the topic:&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;"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;"orders-pubsub"&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-123"&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-42"&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;89.50&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;"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;"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; charset=utf-8"&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;"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-09-11T06:23:21Z"&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-113ad9c4e42b27583ae98ba698d54255-e3743e35ff56f219-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-113ad9c4e42b27583ae98ba698d54255-e3743e35ff56f219-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="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;Your order is in there, one level down, under &lt;code&gt;data&lt;/code&gt;. Going the other way costs nothing: the Function publishes a bare order body with the SDK, and "the subscribing Dapr process still wraps these raw messages in a CloudEvent before delivering them to the subscribing application", so the handler receives an envelope whose &lt;code&gt;data&lt;/code&gt; is the body the Function sent. The direction that breaks is the one you will deploy second, where the Function's existing trigger binds to &lt;code&gt;Order&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="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;ProcessOrder&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="s"&gt;"orders-fn"&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;"ServiceBus"&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="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// order.OrderId    is null&lt;/span&gt;
    &lt;span class="c1"&gt;// order.CustomerId is null&lt;/span&gt;
    &lt;span class="c1"&gt;// order.Total      is 0&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;"Processing order {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;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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The binding deserializes the message body, and the body is now the envelope. &lt;code&gt;topic&lt;/code&gt; and &lt;code&gt;specversion&lt;/code&gt; are not properties of &lt;code&gt;Order&lt;/code&gt;, and &lt;code&gt;OrderId&lt;/code&gt; is not a property of a CloudEvent, so every field lands on its default, with no exception thrown, because the JSON is valid and the type is populated with nothing. The message completes, the delivery count never increments, nothing dead-letters, and &lt;code&gt;DeadletteredMessages&lt;/code&gt; stays flat while every order silently evaporates.&lt;/p&gt;

&lt;p&gt;There is a documented switch that makes the problem go away, and it costs more than it looks. Setting &lt;code&gt;rawPayload: "true"&lt;/code&gt; on publish stops Dapr wrapping the message, but the same page states that disabling CloudEvents &lt;a href="https://docs.dapr.io/developing-applications/building-blocks/pubsub/pubsub-raw/" rel="noopener noreferrer"&gt;"disables support for tracing, event deduplication per messageId, content-type metadata, and any other features built using the CloudEvent schema"&lt;/a&gt;, and the subscribe side is then "always base64 encoded with content type &lt;code&gt;application/octet-stream&lt;/code&gt;". Trading tracing away is a bad deal in a migration whose whole argument is that a single trace spans both sides of it.&lt;/p&gt;

&lt;p&gt;So teach the Function about the envelope rather than teaching Dapr to stop producing it. The shim is a record and one property access:&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;CloudEvent&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;JsonPropertyName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;JsonPropertyName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&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;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;JsonPropertyName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"type"&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;Type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;JsonPropertyName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"traceparent"&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;TraceParent&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;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;ProcessOrder&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="s"&gt;"orders-fn"&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;"ServiceBus"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;CloudEvent&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;message&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;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;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;"Processing order {OrderId} from event {EventId}"&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;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps tracing, keeps &lt;code&gt;messageId&lt;/code&gt; deduplication, and hands the Function the &lt;code&gt;traceparent&lt;/code&gt; it needs to join the Container App's trace. It also survives the Function App being turned off in phase 4. Reach for &lt;code&gt;rawPayload&lt;/code&gt; only when the Function App is genuinely untouchable, and write down what you gave up.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many requests one call actually makes
&lt;/h2&gt;

&lt;p&gt;Phase 3 is about to put production traffic on the new runtime, so this is the last comfortable moment to count what one request there actually costs. The same &lt;code&gt;orders-api&lt;/code&gt; that now shares that subscription also asks &lt;code&gt;inventory-api&lt;/code&gt; whether an order can be filled, and the call site is one 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="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;inventory&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="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;StockCheckRequest&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;Lines&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;inventory&lt;/code&gt; is an &lt;code&gt;HttpClient&lt;/code&gt; built by &lt;code&gt;DaprClient.CreateInvokeHttpClient("inventory-api")&lt;/code&gt; with &lt;code&gt;AddStandardResilienceHandler()&lt;/code&gt; on it, which is what the templates put there. That client, the &lt;code&gt;/stock/check&lt;/code&gt; endpoint it calls and the service defaults wrapping both 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;, the same projects Part 4 built. Count the HTTP requests &lt;code&gt;inventory-api&lt;/code&gt; receives for that one call while it is having a bad minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Layer 1  AddStandardResilienceHandler   1 initial + 3 retries = 4 attempts
Layer 2  Dapr service invocation        1 initial + 3 retries = 4 attempts, per layer-1 attempt
                                                                ------------
         delivered to inventory-api                 4 x 4    =   16

Layer 0  Service Bus redelivery, MaxDeliveryCount 10
         before one message dead-letters           10 x 16   =  160
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Layer 1&lt;/strong&gt; is &lt;code&gt;Microsoft.Extensions.Http.Resilience&lt;/code&gt;. &lt;code&gt;AddStandardResilienceHandler()&lt;/code&gt; chains five strategies from outermost to innermost: a rate limiter, a 30 second total timeout, a retry strategy with max retries 3 and exponential backoff with jitter on a 2 second base delay, a circuit breaker, and a 10 second per-attempt timeout. It retries on HTTP 500 and above, 408 and 429, and by default it retries every HTTP method &lt;a href="https://learn.microsoft.com/dotnet/core/resilience/http-resilience" rel="noopener noreferrer"&gt;including POST&lt;/a&gt;. Three retries means four attempts leave your process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2&lt;/strong&gt; is the sidecar those four attempts pass through. Dapr's built-in service-invocation retries use a 1 second backoff interval with a threshold of 3, so each attempt becomes four sidecar-to-sidecar requests, on a number nobody wrote down in your repository. The version repeated in write-ups goes one further and is wrong: attach your own Dapr retry policy and you would expect 4 x 4 x 4 = 64, but "a user defined retry policy replaces default retries. Targets rely solely on the applied policy." The number stays 16.&lt;/p&gt;

&lt;p&gt;What makes sixteen a migration number rather than a Dapr number is the message underneath it. &lt;code&gt;orders-api&lt;/code&gt; is woken up by a message on the &lt;code&gt;orders-fn&lt;/code&gt; subscription the Function App still reads, and throwing out of the handler abandons that message for redelivery up to &lt;code&gt;MaxDeliveryCount&lt;/code&gt;, which defaults to 10 and replays the whole fan-out each time. That last multiplication is arithmetic on documented defaults rather than a number anyone publishes, so read it as the shape of the problem. &lt;code&gt;MaxDeliveryCount&lt;/code&gt; belongs to the subscription rather than the consumer. That is why the component field of the same name was inert back in the topic section, and why the count only bites during coexistence: every delivery the Container App burns on a failure is one the Function App does not get. Two runtimes, one budget, sized when only one existed.&lt;/p&gt;

&lt;p&gt;The timing interaction is worse than the multiplication, and it is what turns a slow dependency into an outage. Take &lt;code&gt;inventory-api&lt;/code&gt; degraded to 3 seconds per request, not down, just slow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sidecar budget for ONE layer-1 attempt   4 x 3 s + 3 backoffs x 1 s  = 15 s
layer-1 per-attempt timeout                                          = 10 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outer timeout is shorter than the inner retry budget. Polly cancels every attempt at 10 seconds, waits its backoff, and starts a fresh one, so the sidecar's sequence never runs to the end, and the 30 second total timeout leaves room for roughly three of those attempts. The caller pushes seven or eight real requests into an already-degraded dependency and returns a timeout, having succeeded zero times. The defaults in that chain are documented; the interaction between them is reasoning, not a measured run. The rule: &lt;strong&gt;the outermost timeout must exceed the innermost retry budget&lt;/strong&gt;, or the inner layer never finishes and the outer layer only adds load. Either raise the attempt timeout above &lt;code&gt;(maxRetries + 1) * expected_latency + total_backoff&lt;/code&gt;, or take a layer out.&lt;/p&gt;

&lt;p&gt;The instinct at this point is to port the Function App's retry configuration across. Functions runtime retry policies (&lt;code&gt;[FixedDelayRetry]&lt;/code&gt;, &lt;code&gt;[ExponentialBackoffRetry]&lt;/code&gt;) support four trigger types: Cosmos DB, Event Hubs, Kafka and Timer. &lt;strong&gt;Service Bus is not one of them&lt;/strong&gt;, so a Service Bus trigger's retry behaviour has always come from the broker's delivery count, and &lt;code&gt;clientRetryOptions&lt;/code&gt; in &lt;code&gt;host.json&lt;/code&gt; looks like the answer and is not: those settings "only apply to interactions with the Service Bus service. They don't affect retries of &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-bindings-error-pages#retries" rel="noopener noreferrer"&gt;function executions&lt;/a&gt;." Where a policy does carry across, on a Timer or Event Hubs trigger you move later, the retry count "is stored in the memory of the instance", so "the maximum retry count is a best effort". Either way a like-for-like port is a category error: "we retry ten times" meant one thing when a broker counted the deliveries, and on a chained in-process pipeline sitting on a sidecar it means 10 x 16.&lt;/p&gt;

&lt;p&gt;The one lever you own everywhere, including on Container Apps, is layer 1:&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;httpClientBuilder&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="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Retry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DisableForUnsafeHttpMethods&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// POST, PATCH, PUT, DELETE, CONNECT&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;DisableFor(params HttpMethod[])&lt;/code&gt; is the targeted form if you want to keep retrying one unsafe method you know is idempotent. The Dapr layer below retries a POST regardless of what you think about idempotency, and the app layer is the only place the multiplication can be stopped.&lt;/p&gt;

&lt;h3&gt;
  
  
  resiliency.yaml is the right answer and it does not reach your deployment
&lt;/h3&gt;

&lt;p&gt;Dapr has a proper answer, and it is one resource. &lt;code&gt;policies&lt;/code&gt; names the strategies, &lt;code&gt;targets&lt;/code&gt; binds them to an app, a component or an actor type, and both blocks are required:&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;Resiliency&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;orders-resiliency&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;v1alpha1&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="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;policies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;timeouts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;inventoryTimeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;inventoryRetry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;exponential&lt;/span&gt;
        &lt;span class="na"&gt;maxInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
        &lt;span class="na"&gt;maxRetries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
        &lt;span class="na"&gt;matching&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;httpStatusCodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;429,500-599"&lt;/span&gt;
    &lt;span class="na"&gt;circuitBreakers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;inventoryBreaker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;maxRequests&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;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
        &lt;span class="na"&gt;trip&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;consecutiveFailures &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;
  &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;apps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;inventory-api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inventoryTimeout&lt;/span&gt;
        &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inventoryRetry&lt;/span&gt;
        &lt;span class="na"&gt;circuitBreaker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inventoryBreaker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two caveats before you copy it. The &lt;code&gt;apiVersion&lt;/code&gt; is &lt;code&gt;dapr.io/v1alpha1&lt;/code&gt; and the resource is not on Dapr's &lt;a href="https://docs.dapr.io/operations/support/alpha-beta-apis/" rel="noopener noreferrer"&gt;alpha/beta API maturity page&lt;/a&gt;, so do not plan around it as a stable surface. And "currently, resiliency policies are not supported for service invocation via &lt;a href="https://docs.dapr.io/operations/resiliency/resiliency-overview/" rel="noopener noreferrer"&gt;gRPC&lt;/a&gt;", which, since sidecar-to-sidecar traffic is gRPC whatever the caller speaks, means your app must call its sidecar over HTTP rather than that the wire must be HTTP end to end.&lt;/p&gt;

&lt;p&gt;The larger caveat is that on Azure Container Apps the block above does not apply: the Dapr &lt;code&gt;Resiliency&lt;/code&gt; resource is not exposed there. ACA has two unrelated preview features instead, &lt;a href="https://learn.microsoft.com/azure/container-apps/dapr-component-resiliency" rel="noopener noreferrer"&gt;Dapr component resiliency&lt;/a&gt;, which covers &lt;code&gt;outbound&lt;/code&gt; and &lt;code&gt;inbound&lt;/code&gt; hops and has no &lt;code&gt;targets.apps&lt;/code&gt; concept, and &lt;a href="https://learn.microsoft.com/azure/container-apps/service-discovery-resiliency" rel="noopener noreferrer"&gt;service discovery resiliency&lt;/a&gt;, whose documentation states that "you can't apply resiliency policies to requests made by using the Dapr Service Invocation API".&lt;/p&gt;

&lt;p&gt;That closure makes the much-repeated claim "Dapr's built-in retries cannot be disabled" true in a narrower way than it is usually written. The global override is genuinely capped: overrides "are not applied to specific targets", and "you cannot override with lesser values than the provided default value, or completely remove default &lt;a href="https://docs.dapr.io/operations/resiliency/policies/retries/override-default-retries/" rel="noopener noreferrer"&gt;retries&lt;/a&gt;", so &lt;code&gt;maxRetries: 0&lt;/code&gt; under &lt;code&gt;DaprBuiltInServiceRetries&lt;/code&gt; does nothing. A per-target policy replaces the built-in rather than lowering it, and &lt;code&gt;maxRetries: 0&lt;/code&gt; there does take, which a maintainer thread (&lt;a href="https://github.com/dapr/dapr/issues/9625" rel="noopener noreferrer"&gt;dapr/dapr#9625&lt;/a&gt;) documents as the supported workaround. On ACA that per-target route is the one thing you cannot reach: resiliency "is not enabled for Dapr service invocation. And for components it is only enabled if you explicitly create a component resiliency policy" (&lt;a href="https://github.com/microsoft/azure-container-apps/issues/585" rel="noopener noreferrer"&gt;microsoft/azure-container-apps#585&lt;/a&gt;, February 2024).&lt;/p&gt;

&lt;p&gt;So pick one layer to own retries and neuter the others. On Container Apps the choice is made for you: layer 2 is not yours, so layer 1 is where the policy lives.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Own layer 1 deliberately.&lt;/strong&gt; &lt;code&gt;AddStandardResilienceHandler&lt;/code&gt; is per-&lt;code&gt;HttpClient&lt;/code&gt;, so leave it off the client that goes through the sidecar and keep it on the clients that call external HTTP APIs directly. If you keep it on the sidecar client, apply &lt;code&gt;DisableForUnsafeHttpMethods()&lt;/code&gt; and raise &lt;code&gt;AttemptTimeout&lt;/code&gt; above the sidecar's budget so an attempt can complete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure the layer ACA does give you.&lt;/strong&gt; Component resiliency covers the sidecar-to-component and sidecar-to-app hops that pub/sub and state run over:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   az containerapp &lt;span class="nb"&gt;env &lt;/span&gt;dapr-component resiliency create &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;--name&lt;/span&gt; orders-pubsub-resiliency &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;--dapr-component-name&lt;/span&gt; orders-pubsub &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;--environment&lt;/span&gt; aca-env &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;--out-http-retries&lt;/span&gt; 2 &lt;span class="nt"&gt;--out-http-delay&lt;/span&gt; 500 &lt;span class="nt"&gt;--out-http-interval&lt;/span&gt; 5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI defaults if you leave those out are 3 retries, a 1000 ms delay and a 10000 ms interval, and &lt;code&gt;responseTimeoutInSeconds&lt;/code&gt; "includes all retries". Applying a policy requires restarting your Dapr applications, so it is a deployment step, not a hot config change.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Turn the message-level multiplier down while both runtimes share a subscription.&lt;/strong&gt; Judgement rather than documentation: &lt;code&gt;MaxDeliveryCount&lt;/code&gt; at 10 was sized for a single consumer whose only retry mechanism was redelivery, and with a pipeline and a sidecar underneath it, a lower count dead-letters faster and costs the shared dependency less.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Write the number down for one endpoint before you cut it over. Sixteen is fine if you decided on sixteen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: one endpoint at a time
&lt;/h2&gt;

&lt;p&gt;Everything still sitting on the &lt;code&gt;/*&lt;/code&gt; route has live clients on it, so phase 3 is a loop rather than a deploy. One path leaves the catch-all per iteration, and every point inside the iteration has to be somewhere you would be willing to stop.&lt;/p&gt;

&lt;p&gt;The two levers stack rather than blend: the route decides which origin group serves a path, and the weights inside that group decide which runtime serves a request. So one path moves in two motions, carved out of the catch-all onto its own route pointing at a group that holds &lt;strong&gt;both&lt;/strong&gt; origins, then reweighted. &lt;code&gt;/api/customers/*&lt;/code&gt; is the one moving here; &lt;code&gt;/api/reports/*&lt;/code&gt; is still last.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Step 1. One origin group for the path, holding both runtimes.
// customersOriginGroup is name: 'customers-origin-group', and its
// loadBalancingSettings and healthProbeSettings are the same block as
// orders-origin-group in phase 2, additionalLatencyInMilliseconds: 500 included.
// That line is the whole reason the weights below do anything: see the aside.

resource customersFunctionOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2021-06-01' = {
  name: 'customers-functions'
  parent: customersOriginGroup
  properties: {
    hostName: 'orders-func.azurewebsites.net'
    originHostHeader: 'orders-func.azurewebsites.net'
    httpsPort: 443
    priority: 1          // both origins share a priority, deliberately
    weight: 75
    enabledState: 'Enabled'
  }
}

resource customersContainerOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2021-06-01' = {
  name: 'customers-containerapp'
  parent: customersOriginGroup
  properties: {
    hostName: 'orders-api.orangeplant-77e5875b.westeurope.azurecontainerapps.io'
    originHostHeader: 'orders-api.orangeplant-77e5875b.westeurope.azurecontainerapps.io'
    httpsPort: 443
    priority: 1
    weight: 25
    enabledState: 'Disabled'   // deploy dark; step 3 is the only step that moves traffic
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route is the phase 2 shape with different values: &lt;code&gt;patternsToMatch: [ '/api/customers', '/api/customers/*' ]&lt;/code&gt;, pointing at &lt;code&gt;customersOriginGroup&lt;/code&gt;. That is the whole configuration, and the order you apply it in is what makes it reversible:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deploy the group with the new origin disabled.&lt;/strong&gt; The Function App is the only enabled member, so the group behaves exactly like the catch-all did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy the route.&lt;/strong&gt; The path is carved out of &lt;code&gt;/*&lt;/code&gt; and 100% of it still lands on the Function App. Nothing a client can observe changes, which is why it is worth deploying on its own and leaving alone for a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable the Container App origin&lt;/strong&gt;, at the low ratio you already deployed. First contact between new-runtime code and a production request on this path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shift weight and watch.&lt;/strong&gt; Raise the Container App, lower the Function App, repeat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revert by disabling the origin, not by undoing the deployment.&lt;/strong&gt; "If any issues arise with the new origin, disable it to route all traffic back to the old origin", and "when an origin is disabled, both routing and health probes to the origin are also disabled".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is &lt;a href="https://learn.microsoft.com/azure/frontdoor/blue-green-deployment" rel="noopener noreferrer"&gt;Microsoft's own blue/green procedure&lt;/a&gt; with a migration in the middle of it, and Microsoft lists the use case by name under weighted routing: "Application migration to Azure: ... Adjust weights to prefer new origins ... then disable and remove less preferred origins."&lt;/p&gt;

&lt;p&gt;Reversibility is worth costing before you need it, because the five levers are not equally cheap:&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%2Ftyhnv5j93coqadzr2cz2.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%2Ftyhnv5j93coqadzr2cz2.png" alt="Five reversibility levers, what each one reverts by, and the blast radius of pulling it" width="800" height="296"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every row is config only: no redeploy, no pipeline, and any of them can be done by whoever is holding the pager. What none of them is, is a keystroke with an immediate effect. The first four are control-plane changes to Front Door, and a control-plane change reaches the points of presence on Front Door's schedule rather than yours. The last row is the exception, and it exists only if API Management is already in the path. Measure the delay in your own profile, and write the runbook with a window in it rather than a promise.&lt;/p&gt;

&lt;p&gt;Which path goes next is judgement rather than documentation. Move the paths that are neither cacheable nor session-affine first, because both change behaviour when the origin behind a path changes, and neither is something you want to debug on the same afternoon as a runtime change. That is a second reason &lt;code&gt;/api/reports/*&lt;/code&gt; goes last, on top of the profile-wide timeout argument from phase 1.&lt;/p&gt;

&lt;p&gt;One more weighting mechanism shows up in this phase, and it is not on this axis at all. Container Apps splits traffic between &lt;strong&gt;revisions&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az containerapp ingress traffic &lt;span class="nb"&gt;set&lt;/span&gt; &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;--revision-weight&lt;/span&gt; orders-api--knowngood&lt;span class="o"&gt;=&lt;/span&gt;80 orders-api--newrevision&lt;span class="o"&gt;=&lt;/span&gt;20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Weights there must total 100, it needs multiple revision mode, and every name in that command is a revision of &lt;code&gt;orders-api&lt;/code&gt;: no argument accepts a Function App. Front Door weights answer "old runtime or new runtime"; revision weights answer "old version or new version of the service that already moved". The trick worth stealing early is a label on a revision carrying weight 0, a stable URL that takes no production traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Your 90/10 split is a 100/0 split
&lt;/h3&gt;

&lt;p&gt;Set 75 and 25 on the two origins above, deploy, and watch every single request land on one of them. Nothing fails. The portal shows the weights you typed, on the origins you set them on, in the group the route points at, and the traffic ignores all of it.&lt;/p&gt;

&lt;p&gt;Weights are the last of four filters, and the third one has already discarded an origin by the time weighting runs. Front Door picks an origin in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Available.&lt;/strong&gt; Enabled origins that are passing their health probe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Priority.&lt;/strong&gt; Only origins at the best priority value survive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency.&lt;/strong&gt; Only origins within the latency sensitivity range of the fastest one survive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weight.&lt;/strong&gt; Round robin across whatever is left, in your ratio.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stage 3 reads &lt;code&gt;additionalLatencyInMilliseconds&lt;/code&gt; on the origin group, and it defaults to 0. Verbatim, from the &lt;a href="https://learn.microsoft.com/azure/frontdoor/routing-methods" rel="noopener noreferrer"&gt;routing methods page&lt;/a&gt;: "By default, the latency sensitivity property is set to 0 ms. With this setting, requests are always forwarded to the fastest available origins. Weights on the origins only take effect if two origins have the same network latency." Two origins having the same measured network latency is the condition, and a Function App and a Container App do not meet it, ever. Whichever one measures faster from a given point of presence is the only origin still standing after stage 3, and stage 4 round-robins across a set of one. Your 90/10 deploys as 100/0.&lt;/p&gt;

&lt;p&gt;The same pipeline has a second way to do this to you, one stage earlier. If the Function App origin is priority 1 and the Container App origin is priority 2, the Container App gets nothing at all while the Function App is healthy, whatever the weights say. Both origins have to carry the same priority value, which is why the Bicep above sets &lt;code&gt;priority: 1&lt;/code&gt; on both and says so in a comment.&lt;/p&gt;

&lt;p&gt;And the honest caveat for anyone running an internal API at a handful of requests per second: "For customers with very low RPS, due to the distributed nature of Azure Front Door points of presence (POPs) and machines, Azure Front Door can't guarantee that the weights you configure are strictly followed and the load balancing might appear skewed." A clean 90/10 is not something to promise a change board.&lt;/p&gt;

&lt;p&gt;The fix is one property on the origin group, and it is the &lt;a href="https://learn.microsoft.com/azure/frontdoor/blue-green-deployment" rel="noopener noreferrer"&gt;blue/green guide's value&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// before: the default. 90/10 deploys as 100/0.
loadBalancingSettings: {
  sampleSize: 4
  successfulSamplesRequired: 3
  additionalLatencyInMilliseconds: 0
}

// after: both origins stay eligible at stage 3, and 90/10 means 90/10.
loadBalancingSettings: {
  sampleSize: 4
  successfulSamplesRequired: 3
  additionalLatencyInMilliseconds: 500
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirming it took effect is two checks, not one, because the property being deployed and the split being live are different claims. Read the value back off the deployed resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az afd origin-group show &lt;span class="nt"&gt;-g&lt;/span&gt; rg-orders &lt;span class="nt"&gt;--profile-name&lt;/span&gt; afd-orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--origin-group-name&lt;/span&gt; customers-origin-group &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; loadBalancingSettings.additionalLatencyInMilliseconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then count requests at the two origins over a window and compare the ratio to the one you configured, rather than assuming it. Both apps log their own hostname, and Front Door's probes announce themselves with the &lt;code&gt;Edge Health Probe&lt;/code&gt; user agent, so exclude those before you count or a low-traffic path will look busier on both sides than it is. Expect the ratio to be roughly right rather than exactly right, and treat "roughly 75/25" as the property working and "everything on one origin" as it not working.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caching and affinity change what a cutover means
&lt;/h3&gt;

&lt;p&gt;A path you cut over on Tuesday is still answering from the Function App on Friday, and every piece of routing config is correct.&lt;/p&gt;

&lt;p&gt;Only GET requests are cacheable, so &lt;code&gt;POST /api/orders&lt;/code&gt; was never at risk and &lt;code&gt;GET /api/customers/{id}&lt;/code&gt; is, from the moment anyone enables caching on the route. The part that outlives the cutover is what Front Door does when your origin is quiet about it: if the response carries no &lt;code&gt;Cache-Control&lt;/code&gt;, Front Door invents a TTL &lt;a href="https://learn.microsoft.com/azure/frontdoor/front-door-caching" rel="noopener noreferrer"&gt;"between one and three days"&lt;/a&gt; at random. A migration-era endpoint that forgot its cache headers can serve the old runtime's response for days after the origin behind it changed, and none of the five reversibility levers touches it: they all move traffic, not what is already cached.&lt;/p&gt;

&lt;p&gt;Caching also rewrites the request the origin sees: &lt;code&gt;Content-Length&lt;/code&gt;, &lt;code&gt;Transfer-Encoding&lt;/code&gt;, &lt;code&gt;Accept&lt;/code&gt;, &lt;code&gt;Accept-Charset&lt;/code&gt;, &lt;code&gt;Accept-Language&lt;/code&gt; and &lt;code&gt;Vary&lt;/code&gt; are not forwarded, so an API that content-negotiates behaves differently on the same code. And &lt;code&gt;Set-Cookie&lt;/code&gt; is stripped from cacheable responses, which is how caching breaks session affinity without either feature being touched: Front Door's own affinity is not established at all if the origin sends a cacheable response.&lt;/p&gt;

&lt;p&gt;Affinity has a harder constraint underneath it, a dead end rather than a trap. Container Apps sticky sessions (&lt;code&gt;ingress.stickySessions.affinity: "sticky"&lt;/code&gt;) are &lt;a href="https://learn.microsoft.com/azure/container-apps/sticky-sessions" rel="noopener noreferrer"&gt;supported in single revision mode only&lt;/a&gt;, which is exactly what revision traffic splitting cannot run in, so the smoke-test lever from earlier in this phase is not available on an affine path.&lt;/p&gt;

&lt;p&gt;So: leave caching off for &lt;code&gt;/api/*&lt;/code&gt; for the whole migration, which is what omitting &lt;code&gt;--cache-configuration&lt;/code&gt; already did in phase 2, and prove it rather than believe it. Curl a migrated GET and read the &lt;code&gt;X-Cache&lt;/code&gt; header: &lt;code&gt;CONFIG_NOCACHE&lt;/code&gt; is caching being off, &lt;code&gt;TCP_HIT&lt;/code&gt; is a response you are no longer choosing the origin for. Move session-affine paths by path rather than by weight, because a weighted origin group is free to send the second request of a session to the other runtime, and if a path needs sticky sessions, pick between affinity and revision splitting before the cutover window starts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Going private takes the whole environment
&lt;/h3&gt;

&lt;p&gt;The two origin types stop being interchangeable at network lockdown:&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%2Fl3m4f035yfszq8avxeiv.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%2Fl3m4f035yfszq8avxeiv.png" alt="Private Link and origin lockdown compared across a Function App origin and a Container App origin, where the private endpoint scopes to the whole environment" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The load-bearing row is the target sub-resource. The Container Apps private endpoint attaches to &lt;code&gt;managedEnvironments&lt;/code&gt;, so enabling it takes the entire environment private, every app in it, not the one path you migrated last week: you plan the environment, not the app, and you plan it before the first cutover. The row underneath costs you a pattern you probably already have on the Function App, since the &lt;code&gt;AzureFrontDoor.Backend&lt;/code&gt; service tag paired with an &lt;code&gt;x-azure-fdid&lt;/code&gt; header match has no Container Apps ingress equivalent. Origin lockdown on the new runtime is either Private Link or an &lt;code&gt;X-Azure-FDID&lt;/code&gt; check in your own middleware.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you do when it goes wrong
&lt;/h2&gt;

&lt;p&gt;Two hours after &lt;code&gt;/api/orders/*&lt;/code&gt; starts serving from the Container App, the 5xx rate on it triples. You have two questions and only one of them has a good answer: how fast you can put the traffic back, and what putting it back leaves behind. The second one is the expensive one.&lt;/p&gt;

&lt;p&gt;The fast answer depends on which surface owns the routing. With only Front Door in front of the two runtimes, the levers are the ones phase 3 costed. If API Management sits between Front Door and your backends, which is the steady-state shape Part 6 builds, the fastest lever in the migration is a policy edit, because it takes effect at the gateway rather than at the edge:&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;policies&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;inbound&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;base&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;choose&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;when&lt;/span&gt; &lt;span class="na"&gt;condition=&lt;/span&gt;&lt;span class="s"&gt;"@(context.Request.Url.Path.StartsWith("&lt;/span&gt;&lt;span class="err"&gt;/api/orders"))"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;choose&amp;gt;&lt;/span&gt;
          &lt;span class="c"&gt;&amp;lt;!-- {{orders-backend}} is a named value: "containerapp" or "functions".
               One edit in the portal or one az apim nv update, no redeploy. --&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;when&lt;/span&gt; &lt;span class="na"&gt;condition=&lt;/span&gt;&lt;span class="s"&gt;"@("&lt;/span&gt;&lt;span class="err"&gt;{{orders-backend}}"&lt;/span&gt; &lt;span class="err"&gt;==&lt;/span&gt; &lt;span class="err"&gt;"containerapp")"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;set-backend-service&lt;/span&gt; &lt;span class="na"&gt;backend-id=&lt;/span&gt;&lt;span class="s"&gt;"orders-containerapp"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/when&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;otherwise&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;set-backend-service&lt;/span&gt; &lt;span class="na"&gt;backend-id=&lt;/span&gt;&lt;span class="s"&gt;"orders-functions"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/otherwise&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/choose&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/when&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;otherwise&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;set-backend-service&lt;/span&gt; &lt;span class="na"&gt;backend-id=&lt;/span&gt;&lt;span class="s"&gt;"orders-functions"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/otherwise&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/choose&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- Type is Single or Pool. This is how you prove from logs which runtime
         served request X, rather than inferring it from response latency. --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;set-header&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"X-Backend-Type"&lt;/span&gt; &lt;span class="na"&gt;exists-action=&lt;/span&gt;&lt;span class="s"&gt;"override"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;value&amp;gt;&lt;/span&gt;@(context.Backend?.Type ?? "n/a")&lt;span class="nt"&gt;&amp;lt;/value&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/set-header&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/inbound&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;backend&amp;gt;&amp;lt;base&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/backend&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;outbound&amp;gt;&amp;lt;base&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/outbound&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/policies&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;context.Backend&lt;/code&gt; exposes &lt;code&gt;Id&lt;/code&gt;, &lt;code&gt;Type&lt;/code&gt; and &lt;code&gt;AzureRegion&lt;/code&gt;, so the &lt;code&gt;X-Backend-Type&lt;/code&gt; header turns "which runtime served this request" from a guess into a field you can group by. During a mixed week that header is the difference between an incident review and an argument.&lt;/p&gt;

&lt;p&gt;There is one way to get that wrong that you will not enjoy at 3am. &lt;code&gt;set-backend-service&lt;/code&gt; takes either &lt;code&gt;base-url&lt;/code&gt; or &lt;code&gt;backend-id&lt;/code&gt;, and the two do not mix across scopes: if a base policy sets the backend with &lt;code&gt;backend-id&lt;/code&gt;, "it can only be overridden with a policy using the &lt;code&gt;backend-id&lt;/code&gt; attribute, not the &lt;code&gt;base-url&lt;/code&gt; attribute." Pick &lt;code&gt;backend-id&lt;/code&gt; everywhere, including the rollback branch.&lt;/p&gt;

&lt;p&gt;And the backend pool is the one place in this migration where a Function App and a Container App can be given real percentages against each other: up to 30 backends with round-robin, weighted or priority-based balancing and per-backend circuit breakers, with no latency-sensitivity setting standing between the weights you typed and the traffic you get. The docs are honest that "because of the distributed nature of the API Management architecture, backend load balancing is approximate", which is a much smaller caveat than the one phase 3 ran into.&lt;/p&gt;

&lt;h3&gt;
  
  
  The runbook is mostly about the writes
&lt;/h3&gt;

&lt;p&gt;Write this before the cutover, one per migrated endpoint, and keep it to six lines:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Route revert.&lt;/strong&gt; The named value edit above, or &lt;code&gt;az afd route update --route-name orders-route --enabled-state Disabled&lt;/code&gt;, with the resource group already filled in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consumer re-enable.&lt;/strong&gt; The app setting that disabled the Function's &lt;code&gt;orders-fn&lt;/code&gt; trigger (phase 4 names it), and the value to put it back to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stores the new path wrote to&lt;/strong&gt;, by name: the Dapr state store component, its container, and anything the handler touched directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reconciliation query for each.&lt;/strong&gt; Counts and checksums on both sides rather than a row-by-row diff, written out, tested, and dated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead-letter disposition.&lt;/strong&gt; Drain, dead-letter or accept, decided now rather than during the incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The delivery-count assumption.&lt;/strong&gt; What you believe &lt;code&gt;MaxDeliveryCount&lt;/code&gt; is on &lt;code&gt;orders-fn&lt;/code&gt;, so the person reverting knows what budget they are inheriting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Lines 1 and 2 take minutes to write. Lines 3 to 5 are why the document exists, because reverting a route is a control-plane change and a control-plane change has no opinion about data. Three things the new path did while it was live survive the revert intact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;State it committed.&lt;/strong&gt; The write through the sidecar succeeded, and flipping the named value does not un-write it. Dapr does not store your object as your object: it stores an envelope, your document nested under a &lt;code&gt;value&lt;/code&gt; property and the application ID prefixed onto the key. So the old Function path, still reading that container with the Cosmos SDK, finds documents under IDs it never wrote, in a shape it does not deserialize. Do not point both runtimes at one state store during the migration; give each its own and keep them in step through the topic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Messages it published.&lt;/strong&gt; An order event the Container App put on the &lt;code&gt;orders&lt;/code&gt; topic is in the broker. The HTTP revert does not recall it, and the subscriber will process it minutes after you believe the new path is off. This is the line teams skip, and the one that produces the second incident an hour after the first closed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delivery attempts it burned.&lt;/strong&gt; If the Container App consumed a message from &lt;code&gt;orders-fn&lt;/code&gt; and failed, the count is already incremented broker-side, and "the delivery count is increased when a message is received in PeekLock mode and didn't complete the message before the message lock expired." Re-enable the Function consumer and it inherits a partially spent budget on messages it has never seen. The counter does not reset because you changed your mind.&lt;/p&gt;

&lt;p&gt;Re-enabling is not the mirror image of disabling either. It puts a cold app back on a subscription that accumulated backlog for the duration of the failed experiment, with no way to ask whether it came back healthy, which is the next problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  The health signal is not symmetric
&lt;/h3&gt;

&lt;p&gt;You will want a dashboard with the old runtime on the left and the new one on the right. The left column does not exist.&lt;/p&gt;

&lt;p&gt;App Service Health check is the feature you would use, and for the app most likely to be strangled it is not available: "Health check isn't an option for the Flex Consumption and Consumption plans." Where it does exist, it needs an anonymous HTTP trigger answering on the configured path with a 200, which a queue-triggered Function App does not have, and even then a failure only surfaces once ten consecutive one-minute pings cross the load-balancing threshold.&lt;/p&gt;

&lt;p&gt;The Container App side is the opposite problem: startup, readiness and liveness probes plus the sidecar's own &lt;code&gt;dapr.appHealth&lt;/code&gt;, the four-opinion pile-up from phase 1, with readiness at its default failure threshold of 48 on a 5-second period giving a signal roughly four minutes wide. Four health opinions and a four-minute signal on one side, against nothing and a ten-minute signal on the other.&lt;/p&gt;

&lt;p&gt;Front Door does not close that gap, and for the shape phase 2 built it is documented not to: "If you have only a single origin, Azure Front Door always routes traffic to that origin even if its health probe reports an unhealthy status." Microsoft's guidance is to disable probes when a group has one origin, so every probe in &lt;code&gt;orders-origin-group&lt;/code&gt; is telemetry rather than protection. Probes are not free either: volume is per point of presence, so a freshly cut-over Container App running &lt;code&gt;minReplicas: 0&lt;/code&gt; is kept warm, and billed, by probe traffic alone.&lt;/p&gt;

&lt;p&gt;So build for one side. The rollback trigger is the new runtime's error rate and readiness state, grouped by the &lt;code&gt;X-Backend-Type&lt;/code&gt; header, plus the reconciliation queries from line 4 of the runbook. Do not wait for a comparison that the old runtime cannot participate in.&lt;/p&gt;

&lt;h3&gt;
  
  
  There is no feature flag in API Management
&lt;/h3&gt;

&lt;p&gt;The policy above says &lt;code&gt;{{orders-backend}}&lt;/code&gt; rather than anything that looks like a feature flag, and that is not a stylistic choice. API Management has no feature-flag primitive. What it has is a &lt;strong&gt;named value&lt;/strong&gt; (plain, secret, or a Key Vault reference) read as &lt;code&gt;{{name}}&lt;/code&gt; inside a &lt;code&gt;choose&lt;/code&gt;, and a backend pool with per-backend weights. Everything written as "check the feature flag in your APIM policy" is one of those two with a nicer noun on it.&lt;/p&gt;

&lt;p&gt;If the flag genuinely has to live in Azure App Configuration, price the gateway version before you build it. A bare &lt;code&gt;send-request&lt;/code&gt; per inbound request is a full HTTP round trip in the inbound pipeline before the backend has been chosen, with a &lt;code&gt;timeout&lt;/code&gt; that defaults to &lt;strong&gt;60 seconds&lt;/strong&gt;. Caching the lookup is the only workable shape, and the cache is its own project: &lt;code&gt;cache-store-value&lt;/code&gt; is asynchronous, so every expiry lets a burst through to App Configuration at once; the built-in cache is volatile and per-region, so a flag flip lands region by region; it does not exist at all in the Consumption tier; and &lt;code&gt;cache-lookup-value&lt;/code&gt; "is not supported inside a policy fragment", which closes the obvious refactor.&lt;/p&gt;

&lt;p&gt;Then the number that decides the design. In-app, &lt;code&gt;Microsoft.FeatureManagement&lt;/code&gt; with &lt;code&gt;UseFeatureFlags()&lt;/code&gt; refreshes App Configuration feature flags on a &lt;strong&gt;30-second default interval&lt;/strong&gt;, with no cache to design, no tier requirement and no regional skew.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do instead:&lt;/strong&gt; use the named value from the first policy for the routing switch, because it is one edit that applies at the gateway, with no cache in front of it. Reach for the App Configuration lookup only when the rollback genuinely has to beat 30 seconds, and be able to say why it does. One security sentence, because migrations hand out policy-edit rights broadly: anyone holding &lt;code&gt;Microsoft.ApiManagement/service/apis/policies/write&lt;/code&gt; can use &lt;code&gt;authentication-managed-identity&lt;/code&gt; to authenticate as the service identity and take the token with them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cross-runtime trace is two settings away from existing
&lt;/h3&gt;

&lt;p&gt;The part everyone worries about works. Dapr uses W3C trace context on service invocation and pub/sub: "When a request arrives without a trace ID, Dapr creates a new one. Otherwise, it passes the trace ID along the call chain", and "Dapr always propagates trace spans to an application." The &lt;code&gt;traceparent&lt;/code&gt; in the CloudEvent envelope earlier is that contract, in a message. Dapr's docs then warn that you must carry the context from your app's inbound request onto its outbound calls yourself, with no SDK helpers for it. Do not repeat that warning unchanged to a .NET audience: &lt;code&gt;System.Diagnostics.Activity&lt;/code&gt; plus the &lt;code&gt;HttpClient&lt;/code&gt; diagnostics handler already injects &lt;code&gt;traceparent&lt;/code&gt; from the ambient activity, and W3C TraceContext has been the default ID format since .NET 5.&lt;/p&gt;

&lt;p&gt;What actually breaks, in both directions, breaks by omission. Open Application Insights during a mixed week with nothing configured and the Function App contributes no host telemetry, because the isolated worker emits OpenTelemetry only when &lt;code&gt;host.json&lt;/code&gt; says so, while the sidecar hop between the runtimes is missing because Dapr's documented default sample rate is &lt;strong&gt;0.0001&lt;/strong&gt;, one span in ten thousand. Two islands of application-level logging, with a hole where the interesting hop was.&lt;/p&gt;

&lt;p&gt;The obvious fix is closed on Container Apps. Sampling lives at &lt;code&gt;spec.tracing.samplingRate&lt;/code&gt; in a Dapr &lt;code&gt;Configuration&lt;/code&gt; resource, and the first entry in ACA's list of unsupported Dapr capabilities is "Dapr Configuration spec: Any capabilities that require use of the Dapr configuration spec." Neither doc set says whether ACA's managed sidecar keeps the 0.0001 default or sets its own rate once export is enabled, so turn export on and count spans rather than trusting a number from anywhere, including this article.&lt;/p&gt;

&lt;p&gt;Two settings, one per runtime. On the Function App:&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;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.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;"telemetryMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OpenTelemetry"&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;with &lt;code&gt;APPLICATIONINSIGHTS_CONNECTION_STRING&lt;/code&gt; set alongside it. On the Container Apps environment, the property that replaces the sampling knob you cannot reach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;properties: {
  daprAIConnectionString: appInsights.properties.ConnectionString
  openTelemetryConfiguration: {
    tracesConfiguration: {
      includeDapr: true                 // exports the sidecar's own spans
      destinations: [ 'appInsights' ]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After both, one request that enters through the Function App and continues into &lt;code&gt;orders-api&lt;/code&gt; is one &lt;code&gt;operation_Id&lt;/code&gt;, because the W3C to Application Insights mapping is fixed: &lt;code&gt;trace-id&lt;/code&gt; becomes &lt;code&gt;operation_Id&lt;/code&gt; and the span's &lt;code&gt;parent-id&lt;/code&gt; becomes the &lt;code&gt;id&lt;/code&gt; of the request or dependency. The Application Map draws both runtimes and the sidecar hop between them.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;telemetryMode&lt;/code&gt; on the Function App, set &lt;code&gt;includeDapr&lt;/code&gt; on the environment, and point both runtimes at the &lt;strong&gt;same&lt;/strong&gt; Application Insights resource. That last one is not a detail. Two workspaces give you a trace that is technically continuous and practically invisible, because Transaction Diagnostics and the Application Map join on data they can both see. A migration whose main argument is that one trace spans both runtimes should be able to show the trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 4: turning the Function App off
&lt;/h2&gt;

&lt;p&gt;Phase 4 has no new code in it, and the only question it asks is how you know it is safe to stop. &lt;strong&gt;One endpoint is done&lt;/strong&gt; when nothing routes to its Function implementation and nothing triggers it, which is what the end of each pass through phase 3 looks like. &lt;strong&gt;The app is done&lt;/strong&gt; when the last endpoint is done, and that is not the same as the app being gone. Disable, do not delete.&lt;/p&gt;

&lt;p&gt;The route goes first, because it is the only thing here that reverses without touching state. For every path you already moved, the Front Door work finished in phase 3; what is left is the &lt;code&gt;/*&lt;/code&gt; catch-all, still pointing at the Function App origin and still catching everything nobody has looked at in six weeks. Do it in two moves. Set &lt;code&gt;enabledState: 'Disabled'&lt;/code&gt; on the Function App origin, which stops routing and probes together and leaves the route there to re-enable. Then, only after a full business cycle passes without anyone noticing, remove the &lt;code&gt;/*&lt;/code&gt; route. That second move changes what an unrecognised path does, from quietly served by the old runtime to erroring at the edge, which is both the point of it and the reason it goes last.&lt;/p&gt;

&lt;p&gt;The consumer goes next, one function at a time. The message-triggered half of the app has nothing to do with Front Door and has its own switch, &lt;a href="https://learn.microsoft.com/azure/azure-functions/disable-function" rel="noopener noreferrer"&gt;an app setting named after the function&lt;/a&gt;: "You can disable a function in place by creating an app setting in the format &lt;code&gt;AzureWebJobs.&amp;lt;FUNCTION_NAME&amp;gt;.Disabled&lt;/code&gt; set to &lt;code&gt;true&lt;/code&gt;." One function stops and the rest of the app keeps serving.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az functionapp config appsettings &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-func &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;--settings&lt;/span&gt; AzureWebJobs.ProcessOrder.Disabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two caveats travel with that command. The setting is &lt;strong&gt;not honoured for Functions running on Container Apps&lt;/strong&gt;, so if you already moved the Function App into a container this lever does not exist for you. And "changing application settings causes your function app to restart by default across all hosting plans", so disabling one function restarts every other function in the app. Schedule it as a restart, not as a config tweak. Then wait longer than feels reasonable: the grace period for draining in-flight invocations "can extend up to 10 minutes for Consumption plan apps and up to 60 minutes for Flex Consumption and Premium plan apps". Budget an hour on Premium, and check that the Service Bus extension is &lt;a href="https://learn.microsoft.com/azure/azure-functions/event-driven-scaling#scale-in-behaviors" rel="noopener noreferrer"&gt;version 4.2.0 or later&lt;/a&gt; before you rely on the drain rather than after.&lt;/p&gt;

&lt;p&gt;The app itself you stop and keep. Stop it if you want the compute line gone, and leave the code deployed, because reverting this step is &lt;code&gt;AzureWebJobs.ProcessOrder.Disabled&lt;/code&gt; back to &lt;code&gt;false&lt;/code&gt;: the shortest rollback anywhere in this migration, though not a free one, since it costs the same restart and puts a cold Function App back onto whatever backlog built up. Delete the app and that rollback becomes a redeploy from a repository whose pipeline you disabled two weeks ago, at the exact moment you are least interested in fixing a build. Keep it deployed and disabled for one full retention window, long enough that a bug report about an order processed on the old path can still be answered by the app that processed it. The &lt;code&gt;CloudEvent&amp;lt;T&amp;gt;&lt;/code&gt; shim from the topic section stays in that code for the same reason: the Container App goes on publishing envelopes whether or not anything is subscribed.&lt;/p&gt;

&lt;p&gt;One deletion needs planning, and it does not look like it belongs to the Function App at all. The topic and the &lt;code&gt;orders-fn&lt;/code&gt; subscription were pre-created in the Function App's bicep, and the Container App now consumes from them, so deleting that resource group takes away the subscription the migrated app is reading from. Move those two resources into the template that owns the new runtime before you delete anything on the old side, and treat the move as its own change with its own rollback, because a Service Bus subscription that gets deleted and recreated does not bring its messages with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  ActiveMessageCount zero does not mean the old consumer is gone
&lt;/h3&gt;

&lt;p&gt;Disabling the consumer ends in "wait, then confirm", and "confirm" is the step people replace with a metric. The metric they reach for cannot answer the question, twice over.&lt;/p&gt;

&lt;p&gt;The first reason is shape. The Azure Monitor &lt;code&gt;ActiveMessages&lt;/code&gt; metric carries the dimension &lt;code&gt;EntityName&lt;/code&gt; and nothing else, and on a topic the topic-level figure is not a backlog at all: "the active message count on the topic itself is 0, as those messages have been successfully forwarded to the subscription." Per-subscription backlog is &lt;a href="https://learn.microsoft.com/azure/service-bus-messaging/message-counters" rel="noopener noreferrer"&gt;not available from Azure Monitor metrics&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The second survives getting the number right. An active message count counts messages nobody is currently holding, and a message received in &lt;code&gt;PeekLock&lt;/code&gt; and not yet completed is not active, so &lt;code&gt;orders-fn&lt;/code&gt; can read zero while a Function instance is halfway through the last three orders it will ever process. Zero proves the subscription is empty right now, and says nothing about whether anything is still attached to it.&lt;/p&gt;

&lt;p&gt;Take three signals instead, in increasing order of how much they prove:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Function invocation count&lt;/strong&gt; in Application Insights. Necessary, not sufficient: zero invocations is also what a broken trigger looks like, so it tells you when to keep waiting, not when to proceed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-subscription &lt;code&gt;CountDetails&lt;/code&gt;, from the administration API rather than from metrics.&lt;/strong&gt; &lt;code&gt;Get-AzServiceBusSubscription ... | Select CountDetails&lt;/code&gt; returns &lt;code&gt;ActiveMessageCount&lt;/code&gt;, &lt;code&gt;DeadLetterMessageCount&lt;/code&gt;, &lt;code&gt;ScheduledMessageCount&lt;/code&gt; and the two transfer counts; in .NET, &lt;code&gt;SubscriptionRuntimeProperties&lt;/code&gt;. Poll it gently, because "the acquisition of the message counters is an expensive operation inside the message broker, and executing it frequently directly and adversely impacts the entity performance".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ActiveConnections&lt;/code&gt; on the namespace or entity.&lt;/strong&gt; The receiver's AMQP link disappearing is the closest thing to proof that the old consumer let go. It will not drop to zero, because the Container App holds links of its own, so write down the count while both consumers are attached, or the drop you are waiting for has nothing to be a drop from.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;By path. The route is the lever you reach for first, and the reason is what the revert costs: taking &lt;code&gt;/api/orders/*&lt;/code&gt; back off the Container App means deleting one string from &lt;code&gt;patternsToMatch&lt;/code&gt; and letting the catch-all catch it again, with a blast radius of exactly one path pattern and no arithmetic to get wrong at three in the morning. Weights are the second lever rather than the competing one. They belong inside a single origin group, and they mean nothing until &lt;code&gt;additionalLatencyInMilliseconds&lt;/code&gt; stops handing every request to whichever origin measured fastest. Use them when one path is too large to move in a single piece, and not to avoid making the per-path decision.&lt;/p&gt;

&lt;p&gt;None of that is the hard part. The hard part is that almost nothing in this migration fails loudly: a weight that is ignored, a second subscription that appears under your app ID because you left a field blank, a message that deserializes into an object with every field at its default and nothing thrown. Nothing raises and nothing pages, and all of it looks like working software right up until a customer counts their confirmation emails. Most of the work in the four phases is not moving traffic. It is making those failures audible while you are still watching for them.&lt;/p&gt;

&lt;p&gt;Part 6 takes the same three components (Front Door, API Management, Container Apps) and asks what they look like when they are not a transition: the steady-state architecture, sized and secured for the shape you land on rather than the one you pass through.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>dotnet</category>
      <category>cloudnative</category>
      <category>architecture</category>
    </item>
    <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>
  </channel>
</rss>
