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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;p&gt;One temptation to rule out: the query APIs are not a reverse lookup. &lt;code&gt;GetInstanceAsync&lt;/code&gt; needs the ID you are trying to find. &lt;code&gt;GetAllInstancesAsync(OrchestrationQuery)&lt;/code&gt; filters only on runtime status, time range, and orchestration name, with no predicate for an arbitrary business key, so finding &lt;code&gt;R-2048&lt;/code&gt; that way means scanning every instance client-side, an O(n) walk that degrades as history grows and breaks once completed instances are purged. Custom status (&lt;code&gt;SetCustomStatus&lt;/code&gt;) is for surfacing progress and caps at 16 KB; tags are queryable only in the scheduler dashboard, not from code. None of them is a point lookup. A business key to instance ID mapping always comes back to an app-owned index (option 1) or a derivable ID (option 2).&lt;/p&gt;

&lt;p&gt;For the expense workflow the call is short. If a report ID is already a clean single-run key, option 2 removes a whole moving part: the email link encodes the ID and there is nothing to persist or reconcile. The moment you need many approvals per report, audit history, or a key that does not survive the ID character rules, option 1's extra row pays for itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The pause is the easy half. &lt;code&gt;WaitForExternalEvent&lt;/code&gt; suspends for days on a single &lt;code&gt;await&lt;/code&gt; and bills you nothing while it waits, and a &lt;code&gt;CreateTimer&lt;/code&gt; race keeps that wait from becoming a leak. The half that decides whether this works in production is the one most walkthroughs skip: the instance ID is a handle the platform hands you once and never indexes, so reuniting a parked workflow with the human who finally answers it is a design decision you make on purpose, at start time. Get that wrong and the approver clicks a link that raises an event into the void.&lt;/p&gt;

&lt;p&gt;So make the call deliberately on your next approval workflow: do you persist the instance ID in an external table keyed by the business entity, or derive it from the business key so there is nothing to store?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>dotnet</category>
      <category>azurefunctions</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Fan-Out/Fan-In and the Async HTTP Pattern</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 26 Jun 2026 05:00:00 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/fan-outfan-in-and-the-async-http-pattern-194j</link>
      <guid>https://dev.to/martin_oehlert/fan-outfan-in-and-the-async-http-pattern-194j</guid>
      <description>&lt;p&gt;You have 500 order line items to process and no ordering dependency between them, so the question is why the workflow takes as long as 500 activities run back to back when nothing forces them to. The chaining pattern from Part 1 awaits each activity before scheduling the next, which is exactly right when step two needs step one's output and exactly wasteful when the items are independent. The fix is fan-out/fan-in: schedule all the activities at once, then aggregate the results, and the only real trick is doing it in a way that survives the orchestrator replaying. There is also a one-character version of this code that compiles, runs, and silently throws the parallelism away, which is the bug this article spends the most time on.&lt;/p&gt;

&lt;h2&gt;
  
  
  When sequential processing isn't enough
&lt;/h2&gt;

&lt;p&gt;Chaining is the pattern you reach for when the steps form a line: validate the order, then create it, then send the confirmation, each one feeding the next. The &lt;code&gt;await&lt;/code&gt; between steps is load-bearing there, because &lt;code&gt;CreateOrderActivity&lt;/code&gt; genuinely cannot start until &lt;code&gt;ValidateOrderActivity&lt;/code&gt; has returned. That dependency is what makes the sequence correct.&lt;/p&gt;

&lt;p&gt;Now change the shape of the work. A batch order arrives with 500 line items, and each one needs the same per-item processing: check stock, price it, reserve inventory. No line item depends on any other. If you write that as a chain, awaiting each item's activity before scheduling the next, the total wall-clock time is the sum of all 500 activities. At a rough sequential ceiling of about 5 activities per second on a single instance, that batch takes roughly a minute and a half, and every second of it is one item waiting on the item before it for no reason.&lt;/p&gt;

&lt;p&gt;The independence is the whole point. When the items have no ordering relationship, the latency you actually care about is not the sum of the activities but the slowest single one, because there is nothing stopping them from running at the same time. Fan out across the available workers and the same batch finishes in the time of its longest item, plus a little aggregation overhead. The documented fan-out throughput target is around 100 activities per second per instance, an order of magnitude over the sequential figure, and that gap is entirely the difference between running the work in series and running it in parallel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fan-out/fan-in&lt;/strong&gt; is the orchestration pattern for that situation: fan out by scheduling many activities at once, fan in by waiting for all of them and collecting the results. The replay engine from Part 1 is what makes it safe, and the next section shows the exact shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fan-out/fan-in with Task.WhenAll
&lt;/h2&gt;

&lt;p&gt;The shape has two halves. &lt;strong&gt;Fan-out&lt;/strong&gt; means projecting your inputs into activity calls and collecting the tasks without awaiting any of them. &lt;strong&gt;Fan-in&lt;/strong&gt; means a single &lt;code&gt;await Task.WhenAll(tasks)&lt;/code&gt; that completes once every activity has finished, handing you the results.&lt;/p&gt;

&lt;p&gt;Every code sample below is from the &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/DurableFanOutDemo" rel="noopener noreferrer"&gt;companion sample&lt;/a&gt; (isolated worker, .NET 10). Here is the batch processor as an orchestrator, with an aggregation step at the end.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frtrpkouc79azlnw0s1ey.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frtrpkouc79azlnw0s1ey.png" alt="Fan-out/fan-in shape: the orchestrator schedules every ProcessItemActivity at once without awaiting, a single Task.WhenAll waits for all of them and returns the results in input order, then SummarizeBatchActivity aggregates them into a BatchSummary." width="712" height="464"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;Reserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;LineTotal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;BatchSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Processed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Reserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

        &lt;span class="c1"&gt;// Fan-out: schedule every item at once, collect the tasks unawaited.&lt;/span&gt;
        &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;[]&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
            &lt;span class="p"&gt;[..&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
                &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessItemActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;))];&lt;/span&gt;

        &lt;span class="c1"&gt;// Fan-in: one await blocks until all of them complete.&lt;/span&gt;
        &lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WhenAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Optional: hand the collected results to a final aggregation activity.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BatchSummary&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SummarizeBatchActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fan-out is the collection expression. &lt;code&gt;items.Select(...)&lt;/code&gt; projects each &lt;code&gt;OrderItem&lt;/code&gt; into a &lt;code&gt;CallActivityAsync&amp;lt;OrderResult&amp;gt;&lt;/code&gt; call, and because nothing awaits those calls, each one schedules an activity and returns its &lt;code&gt;Task&amp;lt;OrderResult&amp;gt;&lt;/code&gt; immediately. The &lt;code&gt;[.. ...]&lt;/code&gt; spread materializes them into a &lt;code&gt;Task&amp;lt;OrderResult&amp;gt;[]&lt;/code&gt;. After that line, all 500 activities are scheduled; none has been waited on.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;await Task.WhenAll(tasks)&lt;/code&gt; is the fan-in. It returns a single task that completes only when every task in the array has completed, and its result is an &lt;code&gt;OrderResult[]&lt;/code&gt;. The element order matches the order of the &lt;code&gt;tasks&lt;/code&gt; array, not the order the activities happened to finish in, so &lt;code&gt;results[0]&lt;/code&gt; is always the result for &lt;code&gt;items[0]&lt;/code&gt; regardless of which line item processed fastest. That positional guarantee is standard &lt;code&gt;Task.WhenAll&amp;lt;TResult&amp;gt;&lt;/code&gt; behavior, and it is why you can return the array directly without sorting or correlating anything back to its input.&lt;/p&gt;

&lt;p&gt;The activity itself is an ordinary function doing the per-item work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessItemActivity&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessItemActivity&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;OrderResult&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;ActivityTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;OrderItem&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Real work and I/O belong here: check stock, price, reserve inventory.&lt;/span&gt;
        &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;lineTotal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;9.99m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lineTotal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Task.WhenAll&lt;/code&gt; is not one of the nondeterministic APIs banned inside an orchestrator. It is replay-safe, and the reason traces straight back to Part 1's replay engine. The durable calls it aggregates are the replay-checkpointed operations: the runtime records each scheduled &lt;code&gt;CallActivityAsync&lt;/code&gt; in the History table the moment the orchestrator yields, and records each result as it arrives. On a replay the orchestrator reaches the same fan-out line, the framework sees those activities already scheduled (and any already completed), and reconstructs the same task array from history rather than re-running the work. The whole parallel batch therefore survives a host recycle the same way a chain does: the activities run across multiple workers concurrently, and the end-to-end execution is resilient to the orchestrator being unloaded and replayed.&lt;/p&gt;

&lt;p&gt;One behavior to know before you ship this. When several activities fail, &lt;code&gt;await Task.WhenAll(tasks)&lt;/code&gt; throws only the &lt;strong&gt;first&lt;/strong&gt; exception, even though more than one task faulted. If you need to see every failure (to log all of them, or decide based on how many failed) inspect the &lt;code&gt;Exception&lt;/code&gt; property on the task that &lt;code&gt;Task.WhenAll&lt;/code&gt; returns, which holds the full &lt;code&gt;AggregateException&lt;/code&gt; with one inner exception per faulted activity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop mistake (and the fix)
&lt;/h2&gt;

&lt;p&gt;The previous section showed the correct shape. This section is about the version that looks just as correct, compiles cleanly, runs without error, and quietly runs everything in series anyway. It is the most common fan-out bug, and it is worth being able to spot in a code review on sight, because nothing else will flag it for you.&lt;/p&gt;

&lt;p&gt;Here is the broken orchestrator. A reviewer skimming it sees a loop over the items, an activity call per item, and a list of results. It reads like a fan-out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BROKEN: awaiting inside the loop runs the activities one after another.&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessBatchOrchestrator&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OrchestrationTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()!;&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessItemActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// awaits before scheduling the next&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[..&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;await&lt;/code&gt; is in the wrong place. Each iteration calls &lt;code&gt;CallActivityAsync&lt;/code&gt;, then &lt;code&gt;await&lt;/code&gt; suspends the orchestrator until that one activity returns, and only after the result is added to the list does the loop come back around to schedule the next item. So the activities are scheduled one at a time, each waiting on the one before it. This is the chaining pattern wearing a loop, and it has the chaining pattern's latency: the sum of all 500 activities, back at roughly 5 per second. There is no compiler warning, no runtime exception, no log line that looks wrong. The only symptom is that a batch that should take a couple of seconds takes a minute and a half, and you usually only notice once the batch sizes grow in production.&lt;/p&gt;

&lt;p&gt;The fix is to move the &lt;code&gt;await&lt;/code&gt; out of the loop. Schedule everything first, collect the tasks, then fan in with a single &lt;code&gt;Task.WhenAll&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// FIXED: collect every task first, then fan in with one Task.WhenAll.&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessBatchOrchestrator&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OrchestrationTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;TaskOrchestrationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetInput&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()!;&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;[]&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="p"&gt;[..&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessItemActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;))];&lt;/span&gt;   &lt;span class="c1"&gt;// scheduled, not awaited&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WhenAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                &lt;span class="c1"&gt;// all run in parallel&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference is whether &lt;code&gt;await&lt;/code&gt; sits on each individual call or once on the whole array. In the broken version every &lt;code&gt;CallActivityAsync&lt;/code&gt; is awaited the instant it is made, which serializes the schedule-and-wait. In the fixed version the &lt;code&gt;Select&lt;/code&gt; schedules all of them without awaiting any, and the single &lt;code&gt;await Task.WhenAll(tasks)&lt;/code&gt; is the only suspension point, so the activities are in flight together and the worker fans them out across the pool. Same activity, same input, same result array (&lt;code&gt;Task.WhenAll&lt;/code&gt; preserves the order of the &lt;code&gt;tasks&lt;/code&gt; array, so you can return it directly), and at batch scale the difference is roughly 100 activities per second instead of 5.&lt;/p&gt;

&lt;p&gt;The review heuristic: if you see &lt;code&gt;await&lt;/code&gt; on a durable call inside a &lt;code&gt;foreach&lt;/code&gt; or &lt;code&gt;for&lt;/code&gt; loop, stop and ask whether those iterations actually depend on each other. If iteration N needs iteration N-1's output, awaiting in the loop is correct, that is a chain. If they are independent, the &lt;code&gt;await&lt;/code&gt; belongs on a &lt;code&gt;Task.WhenAll&lt;/code&gt; after the loop, and leaving it inside is the silent serialization trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The async HTTP pattern
&lt;/h2&gt;

&lt;p&gt;A 500-item batch that fans out to activities can run for a minute and a half, and no HTTP client should be holding a socket open that long. Browsers, load balancers, and API gateways all time out well before that, so a client function that started the orchestration and blocked on its result would fail the caller before the batch even finished. The async HTTP pattern solves this by separating starting the work from collecting its result: the HTTP trigger kicks off the orchestration and returns immediately with a set of URLs the caller can poll.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgin2j1cisyjdv64egr6p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgin2j1cisyjdv64egr6p.png" alt="Async HTTP pattern: the client POSTs to start the batch and gets back a 202 with a statusQueryGetUri and a Retry-After header, the orchestrator fans out in the background, and the client polls the status endpoint (202 while Running) until it returns 200 with the BatchSummary output." width="712" height="578"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is the client function that starts &lt;code&gt;ProcessBatchOrchestrator&lt;/code&gt; and hands the caller back a status endpoint.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StartBatchClient&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StartBatchClient&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HttpResponseData&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthorizationLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Route&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"batches"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
            &lt;span class="n"&gt;HttpRequestData&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DurableClient&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;DurableTaskClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

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

        &lt;span class="c1"&gt;// 202 Accepted + Location + the management URLs, without blocking on the result.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateCheckStatusResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ScheduleNewOrchestrationInstanceAsync&lt;/code&gt; enqueues the orchestration and returns its instance id without waiting for it to run. &lt;code&gt;CreateCheckStatusResponseAsync&lt;/code&gt; then builds the response: &lt;strong&gt;HTTP 202 Accepted&lt;/strong&gt;, a &lt;code&gt;Location&lt;/code&gt; header pointing at the status-query endpoint, and a &lt;code&gt;Retry-After&lt;/code&gt; header (10 seconds by default) telling the caller how long to wait before polling again. Prefer the awaited &lt;code&gt;CreateCheckStatusResponseAsync&lt;/code&gt; over the synchronous &lt;code&gt;CreateCheckStatusResponse&lt;/code&gt;: under the ASP.NET Core integration the synchronous form can throw &lt;code&gt;InvalidOperationException&lt;/code&gt; ("Synchronous operations are disallowed"), which is why the async overload exists.&lt;/p&gt;

&lt;p&gt;The JSON body carries the management URLs for the instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7f3c1e9a4b8d4f0e9c2a6b5d8e1f0a3c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusQueryGetUri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../runtime/webhooks/durabletask/instances/7f3c...?..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sendEventPostUri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../instances/7f3c.../raiseEvent/{eventName}?..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"terminatePostUri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../instances/7f3c.../terminate?reason={text}&amp;amp;..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"suspendPostUri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../instances/7f3c.../suspend?reason={text}&amp;amp;..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resumePostUri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../instances/7f3c.../resume?reason={text}&amp;amp;..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"purgeHistoryDeleteUri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../instances/7f3c...?..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;statusQueryGetUri&lt;/code&gt; is the one the caller polls (it is the same URL as the &lt;code&gt;Location&lt;/code&gt; header); the others handle raising an external event, terminating, suspending, resuming, and purging the instance's history. A preview &lt;code&gt;rewindPostUri&lt;/code&gt; shows up too on supported plans.&lt;/p&gt;

&lt;p&gt;From the caller's side it is a poll loop against &lt;code&gt;statusQueryGetUri&lt;/code&gt;. While the instance is still running the status endpoint returns &lt;strong&gt;202&lt;/strong&gt;, with its own &lt;code&gt;Location&lt;/code&gt; header pointing back at itself; once the instance reaches a terminal state it returns &lt;strong&gt;200&lt;/strong&gt; with the full status body, and the body's &lt;code&gt;output&lt;/code&gt; field carries the orchestration's return value. The &lt;code&gt;runtimeStatus&lt;/code&gt; field tells you which state you landed in: &lt;code&gt;Running&lt;/code&gt;, &lt;code&gt;Pending&lt;/code&gt;, &lt;code&gt;Failed&lt;/code&gt;, &lt;code&gt;Canceled&lt;/code&gt;, &lt;code&gt;Terminated&lt;/code&gt;, &lt;code&gt;Completed&lt;/code&gt;, or &lt;code&gt;Suspended&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A curl-style poll honoring &lt;code&gt;Retry-After&lt;/code&gt; is the whole client protocol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;HttpResponseMessage&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"https://.../api/batches"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 202 came back; Location is the status-query URL.&lt;/span&gt;
&lt;span class="n"&gt;Uri&lt;/span&gt; &lt;span class="n"&gt;statusUri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;!;&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;HttpResponseMessage&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusUri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;BatchSummary&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StatusResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;// runtimeStatus == "Completed"; the result is in the body's output field.&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 202: still running. Wait the Retry-After the server asked for.&lt;/span&gt;
    &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetryAfter&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Delta&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your caller can tolerate a short synchronous wait (a small batch that usually finishes in a second or two), &lt;code&gt;WaitForCompletionOrCreateCheckStatusResponseAsync&lt;/code&gt; collapses the round trip: it waits for the instance to complete and returns its output with a 200, and if the wait elapses first it falls back to the same 202 + management-URL payload. The isolated-worker signature is worth a careful read, because it differs from the in-process one: it takes a &lt;code&gt;retryInterval&lt;/code&gt; for the internal poll cadence and a &lt;code&gt;CancellationToken&lt;/code&gt; that bounds the overall wait, but there is &lt;strong&gt;no &lt;code&gt;timeout&lt;/code&gt; parameter&lt;/strong&gt;. You cap the wait by cancelling the token, not by passing a &lt;code&gt;TimeSpan&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory and concurrency limits
&lt;/h2&gt;

&lt;p&gt;Two things bite at batch scale that never show up on a three-item demo: where all those activity results go, and how parallel the fan-out actually runs.&lt;/p&gt;

&lt;p&gt;Start with memory. Every activity output is serialized into the orchestration's history in the &lt;code&gt;&amp;lt;TaskHubName&amp;gt;History&lt;/code&gt; table, and Part 1's replay engine loads that full history into memory each time the orchestrator replays. With a 500-wide fan-out, the fan-in array is 500 serialized results sitting in history and getting rehydrated on every replay. If each result is small that is fine; if each activity returns a multi-megabyte payload, history balloons. Past 45 KB serialized, a result spills over to a &lt;code&gt;&amp;lt;taskhub&amp;gt;-largemessages&lt;/code&gt; blob container automatically (the underlying Azure Queue message hard cap is 64 KB, and the 45 KB threshold leaves headroom for the compressed form). That spillover is correctness-preserving, but it is not free: it costs CPU and IO for the compress-and-round-trip, and the rehydrated payloads still bloat replay memory.&lt;/p&gt;

&lt;p&gt;The fix is to return a reference, not the bytes. Have the activity write the heavy payload to blob storage and return a small id the fan-in can carry cheaply.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BROKEN: the multi-MB image is serialized into history on fan-in.&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RenderPageActivity&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;RenderedPage&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;ActivityTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// multi-MB payload&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;RenderedPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// every byte lands in history&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// FIXED: write the payload to blob storage, return a small reference.&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RenderPageActivity&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PageRef&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;ActivityTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;blobName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Blobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UploadAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"pages/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PageRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blobName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// history carries a reference, not the bytes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whatever needs the bytes later reads them back from blob storage by name. The history stays small, replay stays fast, and you never go near the spillover threshold.&lt;/p&gt;

&lt;p&gt;Now concurrency. Fan-out is not unbounded parallelism. Scheduling 500 activities at once does not mean 500 run at once; the host caps how many activities execute concurrently per instance through &lt;code&gt;maxConcurrentActivityFunctions&lt;/code&gt;, which defaults to &lt;strong&gt;10&lt;/strong&gt; on the Consumption plan (10x the processor count on Dedicated and Premium). Orchestrators have their own ceiling, &lt;code&gt;maxConcurrentOrchestratorFunctions&lt;/code&gt;, defaulting to &lt;strong&gt;5&lt;/strong&gt;. Both live under &lt;code&gt;extensions.durableTask&lt;/code&gt; in host.json:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"durableTask"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"maxConcurrentActivityFunctions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"maxConcurrentOrchestratorFunctions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both limits are per-instance, so scale-out multiplies them: ten workers at the default give you up to 100 activities in flight. The surplus past the concurrency limit does not fail, it queues, and the runtime drains it as slots free up. So a 500-wide fan-out on a single instance runs about 10 at a time with the rest waiting their turn, and the batch is only as parallel as your concurrency setting times your worker count allows. Size the fan-out against that ceiling rather than assuming the width you scheduled is the width that runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The whole pattern turns on where one &lt;code&gt;await&lt;/code&gt; sits. Move it off the individual &lt;code&gt;CallActivityAsync&lt;/code&gt; calls and onto a single &lt;code&gt;Task.WhenAll&lt;/code&gt;, and the same orchestrator that ran 500 items in series now runs them in parallel, replay-safe, across every worker the platform gives you. The async HTTP pattern lets a caller start that batch and walk away with a status URL instead of a held-open socket, and the memory and concurrency caps are the guardrails that keep a wide fan-out from quietly bloating history or pretending to be more parallel than it is.&lt;/p&gt;

&lt;p&gt;Part 3 picks up the other half of orchestration: workflows that pause and wait on something outside the function, like a human approval or an external event, without burning compute while they wait.&lt;/p&gt;

&lt;p&gt;Do you cap your fan-out width with a host.json concurrency limit, or let the platform scale it and size the batch to fit?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>dotnet</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Intro to Durable Functions: Orchestrations and the Chaining Pattern</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 19 Jun 2026 06:01:50 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/intro-to-durable-functions-orchestrations-and-the-chaining-pattern-3mc7</link>
      <guid>https://dev.to/martin_oehlert/intro-to-durable-functions-orchestrations-and-the-chaining-pattern-3mc7</guid>
      <description>&lt;p&gt;An order comes in, and you need to validate it, create it, then send a confirmation, three steps that have to run in order and survive a crash halfway through. A single Azure Function can't do that, because the moment it returns it forgets everything, so the usual fix is a chain of queue-triggered functions wired together with a correlation ID, a status table, and your own retry logic. That hand-rolled state machine is exactly what Durable Functions replaces, and the price of admission is learning to write an orchestrator: normal-looking C# that the runtime is allowed to run more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a stateless function can't run a workflow
&lt;/h2&gt;

&lt;p&gt;A plain Azure Function is a single invocation. It receives a trigger, runs, returns, and the worker that ran it can be recycled the instant it finishes. Nothing in the function body survives to the next invocation: no local variables, no "where was I" pointer, no record that step two of a three-step process already succeeded. That design is what makes Functions cheap to scale, and it's the right model for the bulk of event handlers you write.&lt;/p&gt;

&lt;p&gt;It stops being enough the moment one logical unit of work spans more than one step. Take the order example: validate the request, create the order record, send a confirmation. You want those to run in sequence, you want the second step to use the output of the first, and you want the whole thing to pick up where it left off if the host restarts between steps. None of that is possible inside one function, so the standard pattern is to split each step into its own queue-triggered function and pass a message down the chain.&lt;/p&gt;

&lt;p&gt;That works, but look at what you end up owning. You need a &lt;strong&gt;correlation ID&lt;/strong&gt; so you can tell which messages belong to the same order. You need a status table so you can answer "is order 4815 done yet" and so a retry doesn't redo a step that already completed. You need poison-queue handling, timeout logic, and some way to fan results back together if any step branches. You have hand-built a state machine, and state machines spread across five queues are where the 2 a.m. pages come from.&lt;/p&gt;

&lt;p&gt;Durable Functions takes over the state. It records every step your workflow completes in durable storage, and it reconstructs your workflow's position from that record after any interruption. You write the sequence as ordinary C# with &lt;code&gt;await&lt;/code&gt; between the steps; the runtime makes the sequence survive crashes, scale-ins, and host upgrades. The correlation ID, the status table, and the retry bookkeeping all move from your code into the framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three roles: orchestrator, activity, client
&lt;/h2&gt;

&lt;p&gt;Durable Functions splits a workflow into three kinds of function, each identified by a trigger or binding type. Keeping them straight is most of the battle when you're starting out, because the rules about what code is legal where depend entirely on which role you're in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fliegj8nkj5b21jyi1v7s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fliegj8nkj5b21jyi1v7s.png" alt="The three Durable Functions roles: an HTTP-triggered client starts the orchestrator, the orchestrator coordinates the activities, and the task hub in Azure Storage holds the durable state." width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;orchestrator&lt;/strong&gt; is the workflow itself. It's a function marked with the &lt;code&gt;[OrchestrationTrigger]&lt;/code&gt; binding, and its job is to coordinate: call this step, wait for the result, decide what to call next. It contains the control flow (&lt;code&gt;if&lt;/code&gt;, loops, sequencing) but does no real work of its own. The orchestrator is the one role with a hard constraint attached. Its body can be re-executed many times over the life of a single workflow instance, so the code in it must be &lt;strong&gt;deterministic&lt;/strong&gt;. That single fact (the orchestrator replays) is what the rest of this series keeps coming back to; the replay mechanics and the exact list of rules are covered later in this article.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;activity&lt;/strong&gt; is where the actual work happens. It's a function marked with &lt;code&gt;[ActivityTrigger]&lt;/code&gt;, and it's the only one of the three roles allowed to touch the outside world: database writes, HTTP calls, sending email, reading a blob. Activities can bind directly to their input type, so an activity that validates an order can take an &lt;code&gt;OrderRequest&lt;/code&gt; parameter and nothing else. The guarantee Durable Functions gives you on activities is &lt;strong&gt;at-least-once&lt;/strong&gt; execution, and that has a real consequence. An activity can run more than once for the same logical step (after a transient failure and retry, for instance), so activity logic should be &lt;strong&gt;idempotent&lt;/strong&gt;. Sending a confirmation email twice because the worker died after sending but before recording success is the kind of bug this guarantee invites if you're not careful.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;client&lt;/strong&gt; (often called the starter) is the entry point that kicks a workflow off and lets you query it. In the isolated worker model the client is the injected &lt;code&gt;DurableTaskClient&lt;/code&gt;, supplied through the &lt;code&gt;[DurableClient]&lt;/code&gt; binding on a normal trigger such as an HTTP function. It is not something you call from inside an orchestrator; it lives in regular functions that start instances with &lt;code&gt;ScheduleNewOrchestrationInstanceAsync&lt;/code&gt; and hand back a status response the caller can poll.&lt;/p&gt;

&lt;p&gt;Behind all three sits the &lt;strong&gt;task hub&lt;/strong&gt;: the set of Azure Storage resources (queues, tables, and a couple of blob containers) that the default storage provider creates in your function app's storage account to hold the workflow's messages and history. You don't provision it or write to it directly. It's enough to know it exists and that it's where your workflow's durable state actually lives; the history table inside it is the star of the replay section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chaining pattern
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Function chaining&lt;/strong&gt; is the simplest orchestration and the one you'll reach for most. You run a sequence of activities in order, where each step's output feeds the next. Here is the order-processing workflow as an orchestrator.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg82p40xj3tdh5kxxsvp2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg82p40xj3tdh5kxxsvp2.png" alt="Function chaining: the client starts the orchestrator, which calls Validate, then Create, then SendConfirmation in sequence, with each step's output feeding the next." width="799" height="446"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

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

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

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

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

&lt;/div&gt;



&lt;p&gt;Read it top to bottom and it's the sequence from the opening, written as plain C#. &lt;code&gt;context.GetInput&amp;lt;OrderRequest&amp;gt;()&lt;/code&gt; pulls the input the client passed when it started the instance; the &lt;code&gt;!&lt;/code&gt; is there because the input is typed as nullable and you know this orchestrator always gets one. Each &lt;code&gt;await context.CallActivityAsync(...)&lt;/code&gt; schedules an activity and waits for its result before moving on, which is what gives you the chain: &lt;code&gt;validated&lt;/code&gt; gates whether the order is created, and &lt;code&gt;orderId&lt;/code&gt; (the output of &lt;code&gt;CreateOrderActivity&lt;/code&gt;) becomes the input to &lt;code&gt;SendConfirmationActivity&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The call comes in two shapes. When an activity returns a value you use the generic &lt;code&gt;CallActivityAsync&amp;lt;TResult&amp;gt;&lt;/code&gt;, which gives you back a &lt;code&gt;Task&amp;lt;TResult&amp;gt;&lt;/code&gt;: &lt;code&gt;CallActivityAsync&amp;lt;bool&amp;gt;&lt;/code&gt; for the validation result, &lt;code&gt;CallActivityAsync&amp;lt;string&amp;gt;&lt;/code&gt; for the new order ID. When an activity is fire-the-step with nothing to return, you use the non-generic &lt;code&gt;CallActivityAsync&lt;/code&gt;, which returns a plain &lt;code&gt;Task&lt;/code&gt;; that's the confirmation send. The first argument is the activity name as a &lt;code&gt;TaskName&lt;/code&gt;, and since there's an implicit conversion from &lt;code&gt;string&lt;/code&gt;, &lt;code&gt;nameof(ValidateOrderActivity)&lt;/code&gt; works directly and keeps the name refactor-safe.&lt;/p&gt;

&lt;p&gt;The activity is an ordinary function that does the real work. This is where I/O is allowed, so it's where validation against your database or rules engine actually happens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ValidateOrderActivity&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;ActivityTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;OrderRequest&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Real work and I/O belong here, never in the orchestrator:&lt;/span&gt;
        &lt;span class="c1"&gt;// check inventory, validate the customer, hit the database.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The activity binds straight to &lt;code&gt;OrderRequest&lt;/code&gt;, the same type the orchestrator passed as input, so there's no manual deserialization. Keep in mind the at-least-once guarantee from earlier. If &lt;code&gt;ValidateOrderActivity&lt;/code&gt; did something with side effects, you'd want running it twice to be safe. Pure validation like this is naturally idempotent, which is one reason it's a good first step in the chain.&lt;/p&gt;

&lt;p&gt;Something has to start the workflow. That's the client, here an HTTP-triggered function that schedules a new instance and returns a status response the caller can poll.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderClient&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StartOrder&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HttpResponseData&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;StartOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthorizationLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"post"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;HttpRequestData&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DurableClient&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;DurableTaskClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

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

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ScheduleNewOrchestrationInstanceAsync&lt;/code&gt; starts the orchestrator with the deserialized order as input and returns the new instance's ID. &lt;code&gt;CreateCheckStatusResponse&lt;/code&gt; then builds an &lt;code&gt;HttpResponseData&lt;/code&gt; that's an HTTP 202 (Accepted) carrying a set of management URLs (status, terminate, and so on) keyed to that instance ID. The workflow runs asynchronously; the HTTP caller gets an immediate 202 and uses the status URL to find out when the order is done. The orchestrator never starts itself, and the client never contains workflow logic; each role stays in its lane.&lt;/p&gt;

&lt;p&gt;One pin before you copy this into a project: the Durable extension is &lt;code&gt;Microsoft.Azure.Functions.Worker.Extensions.DurableTask&lt;/code&gt; version &lt;strong&gt;1.16.5&lt;/strong&gt; (the 1.x line, even on .NET 10), and &lt;code&gt;TaskOrchestrationContext&lt;/code&gt; lives in &lt;code&gt;Microsoft.DurableTask&lt;/code&gt; while &lt;code&gt;DurableTaskClient&lt;/code&gt; lives in &lt;code&gt;Microsoft.DurableTask.Client&lt;/code&gt;. These are the isolated-worker types; the in-process model used different names (&lt;code&gt;IDurableOrchestrationContext&lt;/code&gt;, a different client), and mixing the two is the most common reason a copied snippet won't compile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replay mechanics
&lt;/h2&gt;

&lt;p&gt;The orchestrator code reads like it runs once, top to bottom. It doesn't. To understand every rule that follows, you have to start from the one mechanic that drives them all: the orchestrator function body runs many times over the life of a single workflow instance.&lt;/p&gt;

&lt;p&gt;Durable Functions doesn't snapshot the orchestrator's current state and resume it. It uses &lt;strong&gt;event sourcing&lt;/strong&gt;. Every action the orchestrator takes (an activity scheduled, an activity completed, a timer created, a result returned) is appended to an &lt;strong&gt;append-only log&lt;/strong&gt; in the History table, which lives in the task hub from the previous section. That log, not the in-memory state of the function, is the source of truth for where the workflow is.&lt;/p&gt;

&lt;p&gt;Here is what actually happens when the order orchestrator runs. The first time, it executes from the top, hits &lt;code&gt;await context.CallActivityAsync&amp;lt;bool&amp;gt;(nameof(ValidateOrderActivity), order)&lt;/code&gt;, and yields. The dispatcher commits "ValidateOrderActivity scheduled" to the History table and unloads the orchestrator from memory entirely. There is now no thread, no stack, nothing in RAM waiting; the workflow exists only as rows in storage. When the validation activity finishes, its result is written to history and the orchestrator is woken back up.&lt;/p&gt;

&lt;p&gt;On that wake-up, the orchestrator runs again from the very first line. It reaches the same &lt;code&gt;CallActivityAsync&lt;/code&gt; call, but this time the framework checks the History table, sees that &lt;code&gt;ValidateOrderActivity&lt;/code&gt; already completed, and &lt;strong&gt;replays the result from history instead of re-running the activity&lt;/strong&gt;. The activity does not execute a second time; the recorded &lt;code&gt;true&lt;/code&gt; (or &lt;code&gt;false&lt;/code&gt;) is read straight out of storage and handed back, the &lt;code&gt;validated&lt;/code&gt; local gets the value it had on the first run, and execution fast-forwards to the first step that hasn't completed yet, &lt;code&gt;CreateOrderActivity&lt;/code&gt;. That step is now scheduled, the orchestrator yields again, and the cycle repeats until &lt;code&gt;SendConfirmationActivity&lt;/code&gt; returns and the orchestrator runs to completion.&lt;/p&gt;

&lt;p&gt;This is precisely what lets a workflow survive a crash. If the host dies after creating the order but before sending the confirmation, the History table still holds "order created" with its result. When a new worker picks the instance up, it replays from the top, fast-forwards past validation and creation using the recorded results, and resumes at exactly the confirmation step. No step that already succeeded runs twice as part of recovery, because recovery is just replay against the same history.&lt;/p&gt;

&lt;p&gt;One practical consequence shows up the first time you add a log line to an orchestrator. It fires on every replay, so a single workflow can emit the same log message several times. The context exposes &lt;code&gt;context.IsReplaying&lt;/code&gt; so you can suppress noise from replayed execution.&lt;br&gt;
&lt;/p&gt;

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

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsReplaying&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Starting order for customer {CustomerId}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... rest of the chain&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;if (!context.IsReplaying)&lt;/code&gt; guard means the "Starting order" line is written once, on the genuine first pass, and skipped on every replay. Without it the line would appear once for every time the orchestrator is dispatched, roughly once per activity in the chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Determinism rules
&lt;/h2&gt;

&lt;p&gt;Replay is also why the orchestrator is the one role with code restrictions. If the body re-executes from the top every time, then any line that produces a different value on the second run than it did on the first will make the workflow take a different path during replay than it took originally, and the state reconstructed from history no longer matches the code's decisions. So the rule is blunt: &lt;strong&gt;orchestrator code must be deterministic&lt;/strong&gt;. The same inputs and the same history must always produce the same sequence of calls.&lt;/p&gt;

&lt;p&gt;These restrictions apply only to orchestrators. Activities can do anything; that's the point of them. It's the orchestrator, and only the orchestrator, that has to behave identically on every pass.&lt;/p&gt;

&lt;p&gt;Here is the trap, written the way it usually gets written:&lt;br&gt;
&lt;/p&gt;

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

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;receivedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// different value every replay&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;           &lt;span class="c1"&gt;// a new GUID every replay&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidateOrderActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both &lt;code&gt;DateTime.UtcNow&lt;/code&gt; and &lt;code&gt;Guid.NewGuid()&lt;/code&gt; look harmless. The problem is that the first run records one timestamp and one GUID, then every replay computes fresh ones. If &lt;code&gt;receivedAt&lt;/code&gt; or &lt;code&gt;traceId&lt;/code&gt; ever feeds a branch, a comparison, or an activity input, the replayed run disagrees with the recorded history and the workflow corrupts. The failure is intermittent (replay only happens after a yield, and only some values flow into decisions), which is exactly what makes it hard to catch in testing and ugly in production.&lt;/p&gt;

&lt;p&gt;The fix is to take time and identity from the context, which returns replay-stable values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// CORRECT: context helpers return the same value on every replay.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;receivedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CurrentUtcDateTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// same instant every replay&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;               &lt;span class="c1"&gt;// same GUID every replay&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;context.CurrentUtcDateTime&lt;/code&gt; records the current UTC time on the first execution and replays that exact instant afterward. &lt;code&gt;context.NewGuid()&lt;/code&gt; produces a replay-safe GUID the same way. (One naming gotcha: the property is &lt;code&gt;CurrentUtcDateTime&lt;/code&gt;. Some docs prose mis-spells it &lt;code&gt;CurrentDateTimeUtc&lt;/code&gt;, which does not exist and will not compile.)&lt;/p&gt;

&lt;p&gt;The same reasoning rules out a few more things. Don't call &lt;code&gt;new Random()&lt;/code&gt; in an orchestrator; if you need randomness, return it from an activity, where the result is saved to history and replayed like any other activity output. Don't read environment variables or configuration directly, since those can change between the first run and a replay hours later; pass config in as orchestrator input or fetch it from an activity. And don't do real I/O (database, file, HTTP) in the orchestrator: it would fire again on every replay, and a network call is never replay-stable anyway. Push all of it into activities.&lt;/p&gt;

&lt;p&gt;Delays have their own replay-safe form. A &lt;code&gt;Task.Delay&lt;/code&gt; or &lt;code&gt;Thread.Sleep&lt;/code&gt; in an orchestrator both blocks a thread and re-evaluates on replay; the durable equivalent is &lt;code&gt;context.CreateTimer&lt;/code&gt;, which records the wake-up time in history and releases the worker entirely until then, so a workflow can wait minutes or days without holding any resources.&lt;/p&gt;

&lt;p&gt;People trip on this rule for a fair reason: the broken code compiles, passes a quick local test, and looks like ordinary C#. The orchestrator only betrays you under replay, and replay only happens after a yield on a worker that may not be the one that started the run. The mental shortcut that keeps you safe is to read every line of an orchestrator and ask whether it would return the same value if the method ran again right now. If the answer is no, it belongs in an activity or behind a context helper.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use Durable Functions vs queues
&lt;/h2&gt;

&lt;p&gt;Durable Functions is not the default answer to "my functions need to talk to each other." A plain storage queue with a table for state is cheaper, simpler to provision, and entirely enough for a large class of problems. If the work is a single hand-off (one function drops a message, another picks it up, does its job, and that's the end of it) a queue is the right tool. Keep those functions stateless and idempotent, carry whatever state you need on the message itself, and you never have to think about replay rules or determinism.&lt;/p&gt;

&lt;p&gt;The line to watch for is &lt;strong&gt;stateful coordination across steps&lt;/strong&gt;. The moment you need the output of one step to drive the next, retries that don't redo work that already succeeded, results from parallel branches aggregated back together, a workflow that waits on an external event or a human approval, durable delays measured in hours or days, or a status endpoint that answers "where is order 4815 right now," a bare queue stops being enough. You can build all of that on queues and tables, but you'll be hand-rolling correlation IDs, a status table, poison-message handling, and timeout plumbing across several queues. That hand-rolled state machine is exactly the thing an orchestrator replaces, and it replaces it with code that reads like the workflow it implements.&lt;/p&gt;

&lt;p&gt;So the honest recommendation: reach for a queue first. Don't pull in Durable Functions for a single fire-and-forget hand-off; the orchestrator's constraints and the task hub's storage footprint are real overhead that buys you nothing there. The signal to switch is the second or third piece of coordination bookkeeping you find yourself writing by hand. When you're maintaining a correlation ID and a status table and retry logic just to keep a multi-step process straight, you've already built a worse version of what Durable Functions gives you, and that's when the orchestration earns its complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Chaining is the first of several orchestration patterns, and it's deliberately the simplest: a straight line of activities. The same replay engine powers fan-out/fan-in (running activities in parallel and aggregating their results), waiting on external events for human-in-the-loop approval, and durable entities for stateful objects. Each is a later part of this series, and each rests on the single fact this article was built around: the orchestrator is C# that replays.&lt;/p&gt;

&lt;p&gt;Which coordination problem pushed you past a plain queue first: a multi-step sequence that needed to survive restarts, fan-out with result aggregation, or waiting on an external event or approval?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>dotnet</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Deploying .NET Aspire Apps to Azure: AZD, ACA, and What Aspire Generates</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:26:46 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/deploying-net-aspire-apps-to-azure-azd-aca-and-what-aspire-generates-4kk1</link>
      <guid>https://dev.to/martin_oehlert/deploying-net-aspire-apps-to-azure-azd-aca-and-what-aspire-generates-4kk1</guid>
      <description>&lt;p&gt;Parts 1 and 2 made one AppHost the source of truth for every connection your functions trigger on, locally. The question for Part 3 is what happens to that graph when you deploy it: which AppHost resources become real Azure infrastructure, and which trigger connections you still have to wire by hand. There are two toolchains that can do the deploy now, so the first decision is which one. This article uses &lt;code&gt;azd&lt;/code&gt;, generates the Bicep, and reads it line by line to find the boundary where the generator stops and you take over.&lt;/p&gt;

&lt;h2&gt;
  
  
  From local to Azure
&lt;/h2&gt;

&lt;p&gt;The AppHost is already a resource graph. Deployment reads that graph and turns it into Azure infrastructure; it is not a second pipeline you write and keep in sync with the first. That is the whole premise: the same &lt;code&gt;AddAzureServiceBus("messaging")&lt;/code&gt; that started an emulator container in Part 2 provisions a real namespace at publish, and the same &lt;code&gt;WithReference&lt;/code&gt; that resolved a local connection string emits an Azure role assignment and an identity-based environment variable.&lt;/p&gt;

&lt;p&gt;Two toolchains turn that graph into Azure today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;azd&lt;/code&gt;&lt;/strong&gt; (Azure Developer CLI): the mature, CI-friendly path. It detects the AppHost, generates Bicep from the resource graph, provisions, and deploys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;aspire deploy&lt;/code&gt;&lt;/strong&gt; (built on the newer &lt;code&gt;aspire publish&lt;/code&gt; / &lt;code&gt;aspire do&lt;/code&gt; pipeline): what Microsoft now recommends as the default for new Aspire projects. The aspire.dev guidance is explicit that &lt;code&gt;azd&lt;/code&gt; "is still supported with Aspire for existing workflows, but it is no longer the recommended default deployment path."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This article uses &lt;code&gt;azd&lt;/code&gt; because it is the path with stable CI/CD integration, federated identity, and a documented pipeline generator, which is what most teams shipping today are on. The infrastructure both toolchains generate is the same; the commands differ. Three commands form the &lt;code&gt;azd&lt;/code&gt; spine: &lt;code&gt;azd init&lt;/code&gt;, &lt;code&gt;azd provision&lt;/code&gt;, &lt;code&gt;azd deploy&lt;/code&gt;. Knowing what each does, and where the split between them matters, is the rest of the deploy story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AZD workflow
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;azd init&lt;/code&gt; run from the solution directory scans the tree and detects the AppHost project. It keys off the Aspire AppHost SDK markers in the &lt;code&gt;.csproj&lt;/code&gt;, not a folder name, and reports the detected service before writing anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;azd init
&lt;span class="go"&gt;? How do you want to initialize your app? Use code in the current directory

Scanning app code in current directory
  (✓) Done: Scanning app code in current directory

Detected services:

  .NET (Aspire)
  Detected in: ./AspireDemo.AppHost/AspireDemo.AppHost.csproj

azd will generate the files necessary to host your app on Azure.

Generating files to run your app on Azure:

  (✓) Done: Generating ./azure.yaml
  (✓) Done: Generating ./next-steps.md

SUCCESS: Your app is ready for the cloud!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it writes is small. An &lt;code&gt;azure.yaml&lt;/code&gt; at the root maps the AppHost to Azure, plus a &lt;code&gt;.azure/&amp;lt;env&amp;gt;/&lt;/code&gt; folder holding per-environment config and an &lt;code&gt;.env&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aspire-demo&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dotnet&lt;/span&gt;
    &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./AspireDemo.AppHost/AspireDemo.AppHost.csproj&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;containerapp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;host: containerapp&lt;/code&gt; records the target family; it does not by itself decide the infrastructure. &lt;code&gt;azd&lt;/code&gt; reads the compute target from the AppHost, which is why the &lt;code&gt;AddAzureContainerAppEnvironment("aca-env")&lt;/code&gt; line added in Part 3's sample matters (more on that below). With &lt;code&gt;azure.yaml&lt;/code&gt; in place, two commands carry the deploy, and they are deliberately separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;azd provision&lt;/code&gt;&lt;/strong&gt; creates and configures the Azure resources from the generated infrastructure. It pushes no application code. Run it and you get a resource group, a Container Apps environment, a registry, the backing services, identities, and role assignments, all empty of your images. Against a real subscription, the Part 3 sample provisioned its ten resources in 6 minutes 25 seconds, the Container Apps environment alone taking 3 minutes 24 of those.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;azd deploy&lt;/code&gt;&lt;/strong&gt; builds your projects, pushes the images to the registry, and creates new Container Apps revisions wired to the provisioned resources. It creates no infrastructure. Same sample: 7 minutes 32 seconds to build the three Functions images plus the Redis container locally, push them, and bring the four apps up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;azd up&lt;/code&gt; runs both in one pass (&lt;code&gt;package&lt;/code&gt; then &lt;code&gt;provision&lt;/code&gt; then &lt;code&gt;deploy&lt;/code&gt;) and is the right call when you are iterating locally and do not care about the seam. In CI/CD the seam is the point: provision changes infrastructure on a slow, reviewed cadence; deploy ships code many times a day. Splitting them lets infra changes gate behind approval while app deploys stay fast.&lt;/p&gt;

&lt;p&gt;One honest caveat the split carries: when only infrastructure changes, &lt;code&gt;azd provision&lt;/code&gt; updates the resources but does not refresh connection values in the already-running apps. You run &lt;code&gt;azd deploy&lt;/code&gt; again to pick those up. The Azure docs put it plainly: when in doubt, use &lt;code&gt;azd up&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Aspire generates
&lt;/h2&gt;

&lt;p&gt;This is the part worth slowing down on, because the generated infrastructure is the contract between your AppHost and Azure, and reading it tells you exactly what you own. The artifact is Bicep. (Aspire also emits an &lt;code&gt;aspire-manifest.json&lt;/code&gt;, but it is now a deprecated compatibility format kept for &lt;code&gt;azd&lt;/code&gt; interop, not the thing to center your mental model on. The Bicep is the truth.)&lt;/p&gt;

&lt;p&gt;Generated from the Part 3 AppHost, the tree is one file per resource with a &lt;code&gt;main.bicep&lt;/code&gt; that stitches them together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;main.bicep                       targetScope = 'subscription'; creates the RG, calls one module per resource
aca-env/aca-env.bicep            managed identity, ACR + AcrPull, Log Analytics, the managed environment
aca-env-acr/aca-env-acr.bicep    the container registry
host-storage/host-storage.bicep  AzureWebJobsStorage account (shared by all three functions)
app-storage/app-storage.bicep    the receipts storage account
messaging/messaging.bicep        the Service Bus namespace + orders queue
cache/cache.bicep                Redis (published as a container)
orders-http/orders-http.bicep    the HTTP-trigger function app (+ identity, + roles modules)
orders-queue/orders-queue.bicep  the queue-trigger function app (+ identity, + roles modules)
orders-sb/orders-sb.bicep        the Service Bus function app (+ identity, + three roles modules)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;main.bicep&lt;/code&gt; targets the subscription scope, creates the resource group, and calls every module with one &lt;code&gt;module&lt;/code&gt; block apiece. The shape is mechanical, by design: every AppHost resource has a one-to-one Bicep module, and the wiring between them is explicit parameters and outputs, not magic.&lt;/p&gt;

&lt;p&gt;The environment module is where Aspire sets up the platform every app runs on. &lt;code&gt;aca-env.bicep&lt;/code&gt; provisions a user-assigned managed identity, an Azure Container Registry with an &lt;code&gt;AcrPull&lt;/code&gt; role assignment for that identity, a Log Analytics workspace, the Container Apps managed environment itself (Consumption profile), and the Aspire dashboard component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resource aca_env 'Microsoft.App/managedEnvironments@2025-07-01' = {
  name: take('acaenv${uniqueString(resourceGroup().id)}', 24)
  location: location
  properties: {
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: aca_env_law.properties.customerId
        sharedKey: aca_env_law.listKeys().primarySharedKey
      }
    }
    workloadProfiles: [
      { name: 'consumption', workloadProfileType: 'Consumption' }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each function project becomes a &lt;code&gt;Microsoft.App/containerApps&lt;/code&gt; resource, and the single most important detail in the tree is the last line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resource orders_sb 'Microsoft.App/containerApps@2025-10-02-preview' = {
  name: 'orders-sb'
  // ...
  identity: {
    type: 'UserAssigned'
    userAssignedIdentities: {
      '${orders_sb_identity_outputs_id}': { }
      '${aca_env_outputs_azure_container_registry_managed_identity_id}': { }
    }
  }
  kind: 'functionapp'
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kind: 'functionapp'&lt;/code&gt; is not cosmetic. It is the Functions-optimized Container Apps variant, and it is what lets the platform derive KEDA autoscaler rules from your trigger attributes instead of you writing scale rules. A plain container app does not get that. Aspire emits &lt;code&gt;kind: 'functionapp'&lt;/code&gt; on all three apps because all three are &lt;code&gt;AddAzureFunctionsProject&lt;/code&gt;, not &lt;code&gt;AddProject&lt;/code&gt;. The deployed apps confirm it: the three Functions apps report &lt;code&gt;functionapp&lt;/code&gt;, the Redis container app reports nothing. One trap if you go check this yourself: ARM only returns &lt;code&gt;kind&lt;/code&gt; from api-version &lt;code&gt;2025-07-01&lt;/code&gt; onward. Query a container app with &lt;code&gt;2024-03-01&lt;/code&gt; and the field is silently absent, which looks exactly like Aspire forgot to set it.&lt;/p&gt;

&lt;p&gt;The connections from Part 2 land as identity-based environment variables, not connection strings. The Service Bus app's container carries exactly what its three &lt;code&gt;WithReference&lt;/code&gt; calls declared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;env: [
  { name: 'messaging__fullyQualifiedNamespace', value: messaging_outputs_servicebusendpoint }
  { name: 'receipts__blobServiceUri',           value: app_storage_outputs_blobendpoint }
  { name: 'AzureWebJobsStorage__blobServiceUri', value: host_storage_outputs_blobendpoint }
  // ...table, queue, dataLake service URIs for host storage...
  { name: 'AZURE_CLIENT_ID',          value: orders_sb_identity_outputs_clientid }
  { name: 'AZURE_TOKEN_CREDENTIALS',  value: 'ManagedIdentityCredential' }
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No secrets for Service Bus or Storage; the app authenticates as its user-assigned identity, and &lt;code&gt;AZURE_TOKEN_CREDENTIALS=ManagedIdentityCredential&lt;/code&gt; pins the credential type so the SDK does not waste startup probing the others. Redis is the exception in this sample: because it is a containerized Redis rather than a managed Azure resource, its connection comes in as an ACA secret (&lt;code&gt;connectionstrings--cache&lt;/code&gt;), not an identity.&lt;/p&gt;

&lt;p&gt;For every referenced resource, Aspire generates a role assignment module. The Service Bus app gets three: &lt;code&gt;AzureServiceBusDataOwner&lt;/code&gt; on the namespace, and Blob plus the rest of the Data Contributor set on the storage accounts it touches. Read the host-storage role module closely; it tells you the default access level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// host-storage roles for orders-sb: three Data Contributor assignments, nothing more
resource host_storage_StorageBlobDataContributor  // ba92f5b4-...
resource host_storage_StorageTableDataContributor // 0a9a7e1f-...
resource host_storage_StorageQueueDataContributor // 974c5e8b-...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three Data Contributor roles. No Storage Account Contributor (that is what &lt;code&gt;.WithHostStorage(...)&lt;/code&gt; drops), and no Blob Data &lt;strong&gt;Owner&lt;/strong&gt;. For most function workloads Data Contributor is enough; if your function needs to manage access policies or container ACLs, the default is too weak and you will widen it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the generated happy path stops: wiring Functions triggers
&lt;/h2&gt;

&lt;p&gt;Everything above wired itself because this sample stays inside the supported set. That set is exactly four integrations: &lt;strong&gt;Azure Blob Storage, Azure Queue Storage, Azure Event Hubs, and Azure Service Bus&lt;/strong&gt;. Hand any of those four to a Functions project with &lt;code&gt;WithReference&lt;/code&gt;, and Aspire wires the connection, the identity, the role assignment, and the KEDA scaler. The Functions integration has been GA since 13.1, so this is dependable behavior rather than a moving target.&lt;/p&gt;

&lt;p&gt;The boundary has sharp edges even on the happy path, and four of them show up in the Bicep you just read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The connection name is a string you have to match by hand.&lt;/strong&gt; On the AppHost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_ServiceBus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-sb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHostStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostStorage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messaging&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;receipts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"receipts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the trigger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ConfirmOrder&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;BlobOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"receipts/{OrderId}.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"receipts"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ConfirmOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ServiceBusTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;OrderMessage&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"messaging"&lt;/code&gt; and &lt;code&gt;"receipts"&lt;/code&gt; appear in both places, and they have to agree. The environment variable Aspire emits is &lt;code&gt;messaging__fullyQualifiedNamespace&lt;/code&gt;; the trigger's &lt;code&gt;Connection = "messaging"&lt;/code&gt; is what reads it. Misspell one side and nothing throws at deploy time. The function just never fires, because the runtime looks for a connection named after the trigger and finds none.&lt;/p&gt;

&lt;p&gt;Matched correctly, the chain holds up live, not just on paper. A message posted to the deployed &lt;code&gt;orders&lt;/code&gt; queue fired &lt;code&gt;ConfirmOrder&lt;/code&gt; under the app's user-assigned identity and wrote the receipt blob, with no connection string anywhere in its environment, running on exactly the &lt;code&gt;AZURE_TOKEN_CREDENTIALS=ManagedIdentityCredential&lt;/code&gt; setup from the generated Bicep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-06-12T08:40:09.84 info: Function.ConfirmOrder.User[0]  Confirm order w24-live-001
2026-06-12T08:40:10.92 warn: Azure.Core  404 The specified container does not exist (ContainerNotFound)
2026-06-12T08:40:11.35 info: Function.ConfirmOrder[2]  Executed 'Functions.ConfirmOrder' (Succeeded, Id=71d7af99-4755-44a9-9f22-d81017f1f180, Duration=2211ms)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that log are worth knowing before you reproduce it. The 404 in the middle is not the connection failing: &lt;code&gt;[BlobOutput]&lt;/code&gt; logs the miss and then creates the &lt;code&gt;receipts&lt;/code&gt; container itself on first write. And the test setup needs data-plane roles you might not expect. The generated namespace and storage accounts disable key-based access (&lt;code&gt;disableLocalAuth&lt;/code&gt;, &lt;code&gt;allowSharedKeyAccess: false&lt;/code&gt;), so even an Owner on the subscription cannot post a test message or list the output blobs; sending the message took a temporary Azure Service Bus Data Sender assignment, and reading the receipt a Storage Blob Data Reader one. Plan both into however you smoke-test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External HTTP ingress is off by default.&lt;/strong&gt; Every app in the generated Bicep, including the HTTP-trigger one, has &lt;code&gt;ingress: { external: false }&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ingress: {
  external: false
  targetPort: 8080
  transport: 'http'
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTP function is reachable inside the Container Apps environment, not from the internet, because the AppHost never called &lt;code&gt;.WithExternalHttpEndpoints()&lt;/code&gt;. If you expected to curl your HTTP trigger after deploy, this is why you cannot. The live deploy spells it out in the hostnames: all four apps come back with &lt;code&gt;*.internal.*&lt;/code&gt; FQDNs, such as &lt;code&gt;orders-http.internal.nicesand-421ce770.westeurope.azurecontainerapps.io&lt;/code&gt;, and the only external URL in the whole deployment belongs to the Aspire dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No scale-to-zero by default.&lt;/strong&gt; Every app has &lt;code&gt;scale: { minReplicas: 1 }&lt;/code&gt;. The function apps do not idle down to zero; you pay for at least one replica each, always. KEDA still scales them up from triggers, but the floor stays at one. There is no &lt;code&gt;rules:&lt;/code&gt; block in the Bicep either; the platform derives the scaler from &lt;code&gt;kind: 'functionapp'&lt;/code&gt;. The deployed apps confirm the floor: all four run with &lt;code&gt;minReplicas: 1&lt;/code&gt; from the moment deploy finishes.&lt;/p&gt;

&lt;p&gt;Step outside the four supported integrations and the wiring stops entirely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anything else needs &lt;code&gt;WithEnvironment&lt;/code&gt; plus an &lt;code&gt;IsPublishMode&lt;/code&gt; branch.&lt;/strong&gt; &lt;code&gt;WithReference&lt;/code&gt; exposes config to client integrations but not to triggers and bindings outside the four. For those you write the environment variable yourself, usually with a publish-mode branch that appends the &lt;code&gt;__serviceUri&lt;/code&gt; suffix for identity-based connections, because the local and published shapes differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing or connection-string-only resources are not picked up.&lt;/strong&gt; A resource added with &lt;code&gt;AddConnectionString(...)&lt;/code&gt;, or the &lt;code&gt;IsPublishMode ? AddAzureServiceBus(...) : AddConnectionString(...)&lt;/code&gt; pattern the Service Bus docs suggest, is not wired into a Functions trigger. This is &lt;a href="https://github.com/microsoft/aspire/issues/6465" rel="noopener noreferrer"&gt;microsoft/aspire #6465&lt;/a&gt;, closed &lt;code&gt;not_planned&lt;/code&gt;. If you were hoping to point a trigger at a pre-existing namespace, that is the gap to plan around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP-trigger access keys are not managed.&lt;/strong&gt; Aspire does not create or rotate Functions access keys. An HTTP trigger that defaults to requiring a key has no key provisioned; you either set the auth level to anonymous or wire Key Vault secrets yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A plain &lt;code&gt;[BlobTrigger]&lt;/code&gt; does not autoscale on ACA.&lt;/strong&gt; Only the Event Grid-based blob trigger source scales; the polling blob trigger does not. If you need a blob-driven function to scale, switch it to the Event Grid source or accept a fixed replica count.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That list is the real payoff. The four-integration happy path is genuinely good, and this sample rides it end to end. The moment your architecture needs a fifth connection type, an existing resource, a public HTTP endpoint, or scale-to-zero, you are writing the wiring, and knowing that before you deploy saves you a silent failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Owning the infrastructure
&lt;/h2&gt;

&lt;p&gt;The instinct when you hit a gap is to open the generated Bicep and edit it. Resist that. Hand-edits to generated Bicep are overwritten the next time the generator runs, on both toolchains. There are two correct ways to own your infrastructure, and editing-then-regenerating is neither.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customise in C# first.&lt;/strong&gt; Most of what you would reach into Bicep for has an AppHost API, and a C# change regenerates deterministically every time. The external-ingress and min-replica gaps from the last section both close in the AppHost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_Http&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-http"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHostStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostStorage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithExternalHttpEndpoints&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                       &lt;span class="c1"&gt;// flips ingress external: true&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PublishAsAzureContainerApp&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;infra&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MinReplicas&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// allow scale-to-zero&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ConfigureInfrastructure&lt;/code&gt; reaches the backing resources the same way, for firewall rules, network ACLs, or a private endpoint added with &lt;code&gt;infra.Add(new PrivateEndpoint(...))&lt;/code&gt;. The enterprise networking story is largely C#-supported, not Bicep-only: a VNet on a new environment via &lt;code&gt;WithDelegatedSubnet&lt;/code&gt;, existing resources via &lt;code&gt;AsExisting&lt;/code&gt; (same subscription), a custom Log Analytics workspace via &lt;code&gt;WithAzureLogAnalyticsWorkspace&lt;/code&gt;. The gaps that still fall back to hand-authored Bicep are narrow: reconfiguring the VNet or volumes on an &lt;em&gt;existing&lt;/em&gt; ACA environment, cross-subscription existing references, and the &lt;code&gt;AcrPull&lt;/code&gt; role assignment when you bring your own registry pull identity.&lt;/p&gt;

&lt;p&gt;One change is mandatory rather than optional, and the Part 3 sample already has it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAzureContainerAppEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"aca-env"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aspire 9.4 removed the hybrid mode where &lt;code&gt;azd&lt;/code&gt; silently owned the Container Apps environment for you. Without this line, &lt;code&gt;aspire publish&lt;/code&gt; and &lt;code&gt;azd&lt;/code&gt; have no compute target to publish into. Older tutorials that call &lt;code&gt;PublishAsAzureContainerApp()&lt;/code&gt; with no environment declared predate that change and no longer work as written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A management-group tag policy put the C#-first advice through a live test.&lt;/strong&gt; The subscription this article deployed to denies any deployment whose resources are missing four tags (&lt;code&gt;cost-center&lt;/code&gt;, &lt;code&gt;owner&lt;/code&gt;, &lt;code&gt;environment&lt;/code&gt;, &lt;code&gt;project&lt;/code&gt;) with values from allowed lists, and the first &lt;code&gt;azd provision&lt;/code&gt; failed on exactly that. The temptation at that moment is to edit tags into ten generated modules. The fix that holds is one C# class. An &lt;code&gt;InfrastructureResolver&lt;/code&gt; participates in &lt;code&gt;Azure.Provisioning&lt;/code&gt;'s Bicep generation and visits every construct, which means it reaches resources that have no first-class customisation hook of their own, like the per-function identities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Azure.Provisioning&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Azure.Provisioning.Primitives&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AzureTagResolver&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;InfrastructureResolver&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;RequiredTags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"cost-center"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"cc-1234"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"platform-team"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"project"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"aspire-demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;ResolveProperties&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProvisionableConstruct&lt;/span&gt; &lt;span class="n"&gt;construct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ProvisioningBuildOptions&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ResolveProperties&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;construct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Not every construct exposes Tags (role assignments don't), hence the probe.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;construct&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ProvisionableResource&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt;
            &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetType&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;GetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Tags"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;GetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;BicepDictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Tags bound to a bicep expression reject item assignment; those resources&lt;/span&gt;
        &lt;span class="c1"&gt;// get a literal rebind in ConfigureInfrastructure instead (below).&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;bicepValue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IBicepValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bicepValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsOutput&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;bicepValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Expression&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;bicepValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kind&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;BicepValueKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;RequiredTags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// ResolveProperties runs until the construct graph stabilises;&lt;/span&gt;
            &lt;span class="c1"&gt;// re-assigning an existing entry keeps it dirty and never converges.&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ContainsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Registered once on the AppHost builder, it stamps every taggable resource in every module and survives every regeneration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configure&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AzureProvisioningOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProvisioningBuildOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InfrastructureResolvers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AzureTagResolver&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two guard clauses in the resolver each cost real debugging time. The idempotency one first: &lt;code&gt;ResolveProperties&lt;/code&gt; runs repeatedly until the construct graph stops changing, and unconditionally re-assigning a tag keeps the graph dirty, so generation never converges. The error you get for that is misleading: &lt;code&gt;azd infra gen&lt;/code&gt; fails with &lt;code&gt;apphost-manifest.json: no such file or directory&lt;/code&gt;, because generation never got far enough to write the manifest. The expression guard second: the Container Apps environment module routes its resources' tags through a module-level &lt;code&gt;tags&lt;/code&gt; parameter, and assigning into a &lt;code&gt;Tags&lt;/code&gt; dictionary bound to an expression throws &lt;code&gt;Cannot assign to Tags, the dictionary is an expression or output only&lt;/code&gt;. The resolver skips those resources, and a &lt;code&gt;ConfigureInfrastructure&lt;/code&gt; callback rebinds their &lt;code&gt;Tags&lt;/code&gt; to a literal instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAzureContainerAppEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"aca-env"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureInfrastructure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;infra&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;infra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetProvisionableResources&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;taggable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OfType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ContainerAppManagedEnvironment&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="n"&gt;Cast&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ProvisionableResource&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OfType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OperationalInsightsWorkspace&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OfType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;UserAssignedIdentity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;());&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;taggable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetType&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;GetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Tags"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;SetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;BicepDictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"cost-center"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"cc-1234"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="c1"&gt;// ...the same four tags&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After those two fixes, every resource the policy evaluates carried the four tags: ten infrastructure resources and four container apps, zero edits to generated files. The detour also mapped where C# ownership ends. The resource group is declared in azd's own &lt;code&gt;main.bicep&lt;/code&gt;, which no AppHost API reaches; tag it by hand and the next &lt;code&gt;azd infra gen&lt;/code&gt; deletes the edit. (Role assignments carry no tags at all; ARM does not support tags on them.) If your policy evaluates resource groups too, that is a policy exemption conversation, or a reason for the lane below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or generate once, then own it.&lt;/strong&gt; When you have a customization with no C# API, the supported escape hatch is to run the generator a single time and stop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;azd infra gen
&lt;span class="go"&gt;Generating infrastructure
Analyzing Aspire Application (this might take a moment...)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That writes the &lt;code&gt;infra/&lt;/code&gt; tree into your repo. From there you commit it, manage it by hand, and stop regenerating. The compose-generate docs bless this workflow. The failure mode to avoid is the middle ground: editing the generated Bicep &lt;em&gt;and&lt;/em&gt; continuing to run the generator, which wipes your edits on the next pass. Pick one lane: customise in C# and keep regenerating, or generate once and own the files.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Actions CI/CD
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;azd pipeline config&lt;/code&gt; wires the repository to Azure and drops a workflow into &lt;code&gt;.github/workflows/&lt;/code&gt;. It creates an app registration, assigns a role, configures federated identity, and pushes the generated &lt;code&gt;azure-dev.yml&lt;/code&gt;. OIDC is the default, which is the detail that matters: there is no client secret stored anywhere. The identity values land as repository &lt;strong&gt;variables&lt;/strong&gt; (&lt;code&gt;AZURE_CLIENT_ID&lt;/code&gt;, &lt;code&gt;AZURE_TENANT_ID&lt;/code&gt;, &lt;code&gt;AZURE_SUBSCRIPTION_ID&lt;/code&gt;), not as the legacy &lt;code&gt;AZURE_CREDENTIALS&lt;/code&gt; JSON secret.&lt;/p&gt;

&lt;p&gt;The generated workflow is closer to production-ready than its reputation suggests. It does not run &lt;code&gt;azd up&lt;/code&gt;. It already runs provision and deploy as separate steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install azd&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Azure/setup-azd@v2&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install .NET for Aspire&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-dotnet@v4&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;dotnet-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.x&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Log in with Azure (Federated Credentials)&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azd auth login --client-id "$AZURE_CLIENT_ID" --federated-credential-provider github --tenant-id "$AZURE_TENANT_ID"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Provision&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azd provision --no-prompt&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azd deploy --no-prompt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the default does &lt;em&gt;not&lt;/em&gt; do is gate or separate the cadence. Both steps run in the same job, on every push, unreviewed. The advisable edit is to split by cadence and approval: move &lt;code&gt;azd provision&lt;/code&gt; into its own job gated behind a GitHub Environment with required reviewers (infra changes are rare and want a human), and keep &lt;code&gt;azd deploy&lt;/code&gt; as the frequent job on app commits. Each job needs its own &lt;code&gt;id-token: write&lt;/code&gt; and its own &lt;code&gt;azd auth login&lt;/code&gt;, because OIDC tokens and &lt;code&gt;azd&lt;/code&gt; login state do not cross job boundaries. This is a hand edit, not a flag, and it ties straight back to the provision/deploy split from earlier, now enforced by the pipeline.&lt;/p&gt;

&lt;p&gt;For configuration, &lt;code&gt;azd&lt;/code&gt; stores per-environment values in &lt;code&gt;.azure/&amp;lt;env&amp;gt;/.env&lt;/code&gt; and projects them into pipeline variables. Custom variables and secrets go under a &lt;code&gt;pipeline:&lt;/code&gt; block in &lt;code&gt;azure.yaml&lt;/code&gt;, each matching an environment key, and you rerun &lt;code&gt;azd pipeline config&lt;/code&gt; after editing. Key Vault references via &lt;code&gt;azd env set-secret&lt;/code&gt; (&lt;code&gt;akvs://...&lt;/code&gt;) keep rotation out of the pipeline when stored as variables; secured Bicep parameters with no default get bundled into a single &lt;code&gt;AZD_INITIAL_ENVIRONMENT_CONFIG&lt;/code&gt; secret on the provision step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The boundary is the whole story. Aspire generates real, readable Bicep that wires identities, roles, and connections for the four integrations it supports, and it stops at a set of edges you can name: connection-name matching, external ingress, scale-to-zero, and everything outside those four integrations. Read the generated infrastructure once and you know which side of that line each piece of your app is on.&lt;/p&gt;

&lt;p&gt;That closes Series 3. Part 1 made the AppHost the source of truth, Part 2 declared the Azure services as resources, and Part 3 deployed the graph and found where the generator stops.&lt;/p&gt;

&lt;p&gt;Do you let Aspire generate your Azure infrastructure and customise it in C#, or do you generate once and own the Bicep by hand from there?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>aspire</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Azure Services as Aspire Resources: Service Bus, Storage, and Redis</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 05 Jun 2026 06:17:53 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/azure-services-as-aspire-resources-service-bus-storage-and-redis-2260</link>
      <guid>https://dev.to/martin_oehlert/azure-services-as-aspire-resources-service-bus-storage-and-redis-2260</guid>
      <description>&lt;p&gt;A &lt;code&gt;[ServiceBusTrigger("orders", Connection = "messaging")]&lt;/code&gt; attribute doesn't hold a connection string. It holds the name &lt;code&gt;"messaging"&lt;/code&gt;, and something has to resolve that name to a real connection in every environment the function runs in. Part 1 moved one connection (host storage) out of &lt;code&gt;local.settings.json&lt;/code&gt; and into the AppHost. The question this part answers is whether the same move holds for the connections you actually choose: a Service Bus namespace, a second storage account, a Redis cache, three services that in a traditional Functions app are three strings a developer pastes per machine and hopes match production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The connection string problem, widened
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/AspireDemo" rel="noopener noreferrer"&gt;companion sample&lt;/a&gt; from Part 1 had two Functions projects sharing one host-storage emulator. That covered the connection the runtime needs for its own bookkeeping, the one injected as &lt;code&gt;AzureWebJobsStorage&lt;/code&gt;. It didn't cover the connections your code triggers on.&lt;/p&gt;

&lt;p&gt;Part 2 adds a third worker, &lt;code&gt;OrderProcessor.ServiceBus&lt;/code&gt;, that consumes a Service Bus queue, dedupes against Redis, and writes a receipt blob to a storage account that isn't host storage. That one function touches three connections you name yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConfirmOrderFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ConfirmOrderFunction&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;OrderValidator&lt;/span&gt; &lt;span class="n"&gt;validator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IConnectionMultiplexer&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ConfirmOrder&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;BlobOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"receipts/{OrderId}.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"receipts"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ConfirmOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ServiceBusTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;OrderMessage&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffpz620n0ezit753hhe8a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffpz620n0ezit753hhe8a.png" alt="End-to-end path: a Service Bus message flows through the trigger, into the Redis idempotency gate, and out to a blob receipt; a duplicate delivery short-circuits to null." width="712" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In a traditional setup each of those (&lt;code&gt;messaging&lt;/code&gt;, &lt;code&gt;receipts&lt;/code&gt;, and the Redis connection the multiplexer reads) is a string in &lt;code&gt;local.settings.json&lt;/code&gt;, copied from the portal, kept current by hand on every machine and in every environment. Three names, three places to drift. The fix is the one from Part 1, applied three times: declare each service once in the AppHost as a &lt;strong&gt;resource&lt;/strong&gt;, hand it to the Functions project by reference, and let Aspire compute the connection value per environment. What stays is the short name. &lt;code&gt;"messaging"&lt;/code&gt; appears in the AppHost and on the trigger, and those two have to agree. The value behind the name stops being something you maintain.&lt;/p&gt;

&lt;p&gt;The rest of this article is those three declarations and what each one resolves to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service Bus as an Aspire resource
&lt;/h2&gt;

&lt;p&gt;The package is &lt;a href="https://www.nuget.org/packages/Aspire.Hosting.Azure.ServiceBus" rel="noopener noreferrer"&gt;&lt;code&gt;Aspire.Hosting.Azure.ServiceBus&lt;/code&gt;&lt;/a&gt;. Two lines in &lt;code&gt;AppHost.cs&lt;/code&gt; declare the namespace and a queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;messaging&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAzureServiceBus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RunAsEmulator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;messaging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddServiceBusQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddAzureServiceBus("messaging")&lt;/code&gt; returns the namespace resource. Children come from &lt;code&gt;AddServiceBusQueue&lt;/code&gt; / &lt;code&gt;AddServiceBusTopic&lt;/code&gt; / &lt;code&gt;AddServiceBusSubscription&lt;/code&gt; (the older &lt;code&gt;AddQueue&lt;/code&gt; / &lt;code&gt;AddTopic&lt;/code&gt; / &lt;code&gt;AddSubscription&lt;/code&gt; names were obsoleted in 9.1, so a 9.x snippet from the Learn API reference won't compile against 13.x).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RunAsEmulator()&lt;/code&gt; is the line that earns the section. Locally it starts the &lt;a href="https://learn.microsoft.com/azure/service-bus-messaging/overview-emulator" rel="noopener noreferrer"&gt;Service Bus emulator&lt;/a&gt; as containers Aspire owns. In publish mode it's a no-op, so the same two lines provision a real namespace under &lt;code&gt;azd&lt;/code&gt;. One declaration, two resolutions.&lt;/p&gt;

&lt;p&gt;Two things about that emulator are worth knowing before you run it.&lt;/p&gt;

&lt;p&gt;First, it isn't one container, it's two. The emulator needs a SQL backend, so Aspire pulls &lt;code&gt;mcr.microsoft.com/azure-messaging/servicebus-emulator:2.0.0&lt;/code&gt; plus &lt;code&gt;mcr.microsoft.com/mssql/server:2022-latest&lt;/code&gt;, generates the SQL &lt;code&gt;sa&lt;/code&gt; password, sets &lt;code&gt;ACCEPT_EULA=Y&lt;/code&gt; on both, and injects the SQL connection into the emulator. You write one line; Aspire coordinates two containers and the secret between them. No &lt;code&gt;.env&lt;/code&gt; file to edit.&lt;/p&gt;

&lt;p&gt;Second, the emulator doesn't create entities at runtime. It reads a &lt;code&gt;Config.json&lt;/code&gt; at startup and provisions exactly what's in it. Aspire generates that file from your &lt;code&gt;AddServiceBusQueue("orders")&lt;/code&gt; declaration and mounts it, so the queue exists when the worker connects. On the live run the emulator logged &lt;code&gt;Creating queue: orders&lt;/code&gt; then &lt;code&gt;Emulator Service is Successfully Up!&lt;/code&gt;. If you need topics, subscriptions, or rules the emulator config supports but the fluent API doesn't reach, &lt;code&gt;WithConfigurationFile&lt;/code&gt; and &lt;code&gt;WithConfiguration&lt;/code&gt; are the escape hatches.&lt;/p&gt;

&lt;p&gt;The trigger side is where Service Bus pays off the resource model, because Service Bus is one of the four integrations Aspire auto-wires into the Functions binding system. On the AppHost you hand the namespace to the worker by reference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_ServiceBus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-sb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHostStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostStorage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messaging&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second argument to &lt;code&gt;WithReference&lt;/code&gt; is the connection name. The trigger names the same string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ServiceBusTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;OrderMessage&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole contract. Aspire computes the connection value (the emulator's local endpoint now, a real namespace under &lt;code&gt;azd&lt;/code&gt; later) and injects it under &lt;code&gt;messaging&lt;/code&gt;; the trigger resolves &lt;code&gt;messaging&lt;/code&gt; and binds. The name matches because you typed it twice, not because anything derives it. Omit the second &lt;code&gt;WithReference&lt;/code&gt; argument and it defaults to the resource name, which here is also &lt;code&gt;messaging&lt;/code&gt;, so it would resolve either way; the explicit form is clearer about what the contract is. The worker needs &lt;a href="https://www.nuget.org/packages/Microsoft.Azure.Functions.Worker.Extensions.ServiceBus" rel="noopener noreferrer"&gt;&lt;code&gt;Microsoft.Azure.Functions.Worker.Extensions.ServiceBus&lt;/code&gt;&lt;/a&gt; (5.24.0 against Worker 2.x) for the trigger attribute to exist.&lt;/p&gt;

&lt;p&gt;Be honest with your team about the emulator's limits. Microsoft labels it dev/test only, production use is explicitly discouraged. It speaks AMQP over TCP, and it doesn't persist messages across a restart; entities re-provision from &lt;code&gt;Config.json&lt;/code&gt;, but in-flight messages are gone. On Apple Silicon both images are amd64-only and run under Rosetta. That sounds like a caveat but it's the good kind: on the author's M-series machine the full message → trigger → blob path ran end to end with no special Docker flags beyond having Rosetta emulation available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage beyond host storage
&lt;/h2&gt;

&lt;p&gt;Part 1 used one storage resource for host bookkeeping. Application data shouldn't share it. The receipt this worker writes is your data, not the runtime's lease blobs and scaling state, so it gets its own resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;appStorage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAzureStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"app-storage"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RunAsEmulator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;receipts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddBlobs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"receipts"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RunAsEmulator()&lt;/code&gt; here is &lt;a href="https://learn.microsoft.com/azure/storage/common/storage-use-azurite" rel="noopener noreferrer"&gt;Azurite&lt;/a&gt;, pulled as &lt;code&gt;mcr.microsoft.com/azure-storage/azurite:3.35.0&lt;/code&gt; and started and stopped with the AppHost. There's no &lt;code&gt;npm install -g azurite&lt;/code&gt;, no second terminal running &lt;code&gt;azurite --silent&lt;/code&gt;, no hand-written &lt;code&gt;devstoreaccount1&lt;/code&gt; string. The prerequisite step from every Functions README becomes a line in a project the team checks in.&lt;/p&gt;

&lt;p&gt;One storage resource fans out to all three services. &lt;code&gt;AddBlobs&lt;/code&gt; and &lt;code&gt;AddQueues&lt;/code&gt; give you the blob and queue endpoints; &lt;code&gt;AddTables&lt;/code&gt; gives you the table service. Note the plural: there's no singular &lt;code&gt;AddTable&lt;/code&gt; in 13.x, and Aspire 9.4 moved these to top-level calls on the storage resource (&lt;code&gt;AddBlobContainer&lt;/code&gt;, &lt;code&gt;Add*ServiceClient&lt;/code&gt; on the client side), so older snippets miss the rename. Blob and Queue auto-wire into triggers the same way Service Bus does; Tables do not, and need the &lt;code&gt;WithEnvironment&lt;/code&gt; form the last section covers.&lt;/p&gt;

&lt;p&gt;The blob output binding is auto-wired by the same &lt;code&gt;WithReference&lt;/code&gt; pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_ServiceBus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-sb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHostStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostStorage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messaging&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"messaging"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;receipts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"receipts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WithReference(receipts, "receipts")&lt;/code&gt; injects the connection the &lt;code&gt;[BlobOutput(... Connection = "receipts")]&lt;/code&gt; attribute names. The function returns an &lt;code&gt;Order&lt;/code&gt;, and Aspire serializes it to &lt;code&gt;receipts/{OrderId}.json&lt;/code&gt;. The &lt;code&gt;{OrderId}&lt;/code&gt; token in that path is worth a beat: it resolves from the Service Bus trigger's POCO, the &lt;code&gt;OrderMessage&lt;/code&gt; that fired the function, not from any injected client. On the live run a message with &lt;code&gt;OrderId&lt;/code&gt; &lt;code&gt;w23-001&lt;/code&gt; produced &lt;code&gt;receipts/w23-001.json&lt;/code&gt; containing the serialized order. The blob binding read a value off the message that triggered it, across two different Azure services, with no glue code between them.&lt;/p&gt;

&lt;p&gt;Ports are mapped dynamically, so never hardcode &lt;code&gt;127.0.0.1:10000&lt;/code&gt;; read the connection from the injected value. Azurite is in-memory by default. If you want receipts to survive a restart, &lt;code&gt;WithDataVolume()&lt;/code&gt; or &lt;code&gt;WithDataBindMount()&lt;/code&gt; opts into persistence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redis as an Aspire resource
&lt;/h2&gt;

&lt;p&gt;Redis is the resource that breaks the pattern, and the break is the useful part of the section. The declaration looks like the others:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://learn.microsoft.com/dotnet/aspire/caching/stackexchange-redis-integration" rel="noopener noreferrer"&gt;&lt;code&gt;AddRedis&lt;/code&gt;&lt;/a&gt; (from &lt;code&gt;Aspire.Hosting.Redis&lt;/code&gt;) runs a local Redis container with health checks; at publish it deploys containerized Redis on Container Apps. For a managed cache in production the current API is &lt;code&gt;AddAzureManagedRedis&lt;/code&gt; (Azure Managed Redis, Entra ID auth by default). Don't reach for &lt;code&gt;AddAzureRedis&lt;/code&gt;, &lt;code&gt;PublishAsAzureRedis&lt;/code&gt;, or &lt;code&gt;.RunAsContainer()&lt;/code&gt; on the Azure Redis resource; all three are &lt;code&gt;[Obsolete]&lt;/code&gt; in 13.x.&lt;/p&gt;

&lt;p&gt;The break is that &lt;strong&gt;Redis is not auto-wired into the Functions trigger system&lt;/strong&gt;. The four integrations that feed triggers and bindings are Blob, Queue, Event Hubs, and Service Bus. Redis isn't one of them, which means &lt;code&gt;WithReference(cache)&lt;/code&gt; on its own does not make a Redis trigger resolve. What &lt;code&gt;WithReference(cache)&lt;/code&gt; does serve is the Aspire client integration: register &lt;code&gt;IConnectionMultiplexer&lt;/code&gt; in the worker with one line and read the cache from code triggered by something else.&lt;/p&gt;

&lt;p&gt;That's the path this sample uses. In &lt;code&gt;Program.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedisClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cache"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the function takes &lt;code&gt;IConnectionMultiplexer&lt;/code&gt; by constructor injection and uses Redis as an idempotency gate, because Service Bus delivers at-least-once and a retry can replay the same order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetDatabase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;firstDelivery&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StringSetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;$"orders:seen:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;When&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotExists&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;firstDelivery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Duplicate order {OrderId}, skipping receipt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail from the live run is worth a callout, because it's the kind of thing that costs an afternoon if you hit it raw. Aspire's 13.x Redis container ships with TLS and auth on by default: it runs &lt;code&gt;redis-server --requirepass &amp;lt;generated&amp;gt; --tls-port 6379 ...&lt;/code&gt; with Aspire-issued certs. A naive &lt;code&gt;redis-cli&lt;/code&gt; against it gets "Connection reset by peer" because it spoke plaintext to a TLS port. &lt;code&gt;AddRedisClient("cache")&lt;/code&gt; handles the handshake and the generated password from the injected connection string, so the worker code stays at &lt;code&gt;IConnectionMultiplexer&lt;/code&gt; and &lt;code&gt;GetDatabase()&lt;/code&gt;. Hand-rolling the connection would mean configuring TLS and the secret yourself.&lt;/p&gt;

&lt;p&gt;If you genuinely want to trigger on Redis (Pub/Sub, List, or Stream), the &lt;a href="https://www.nuget.org/packages/Microsoft.Azure.Functions.Worker.Extensions.Redis" rel="noopener noreferrer"&gt;&lt;code&gt;Microsoft.Azure.Functions.Worker.Extensions.Redis&lt;/code&gt;&lt;/a&gt; extension exists, but two things apply. The trigger attribute's first argument is an app-setting name, so you wire the connection with the &lt;code&gt;WithEnvironment&lt;/code&gt; form from the next section rather than &lt;code&gt;WithReference&lt;/code&gt;. And the Redis triggers run only on Elastic Premium or dedicated App Service plans, not Consumption or Flex Consumption. For the serverless default, the &lt;code&gt;IConnectionMultiplexer&lt;/code&gt;-via-DI path above is the one that works on every plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Aspire resolves dev versus production
&lt;/h2&gt;

&lt;p&gt;Three declarations, three behaviors at the trigger boundary. The rule underneath them is exact, so state it exactly: Aspire auto-wires four integrations into the Functions binding system through &lt;code&gt;WithReference&lt;/code&gt;, and those four are Blob Storage, Queue Storage, Event Hubs, and Service Bus. For one of those, &lt;code&gt;WithReference(resource, "name")&lt;/code&gt; is the whole wiring. For anything else (Redis, Cosmos, SQL, Tables, a custom service), you set the env var yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_ServiceBus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-sb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"RedisConnection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectionStringExpression&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line per non-auto-wired resource. Real, but bounded, and the same shape every time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkqwbs7jt3rl67bko3uru.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkqwbs7jt3rl67bko3uru.png" alt="Aspire auto-wires four integrations (Service Bus, Blob, Queue, Event Hubs) into Functions bindings through WithReference; every other resource takes an explicit WithEnvironment line." width="712" height="282"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What the name buys you is single-sourcing of the connection &lt;em&gt;value&lt;/em&gt;, not the name. &lt;code&gt;WithReference(messaging, "messaging")&lt;/code&gt; and &lt;code&gt;[ServiceBusTrigger(Connection = "messaging")]&lt;/code&gt; agree because you wrote &lt;code&gt;"messaging"&lt;/code&gt; in both places. Aspire computes what that name resolves to and how that changes between local and published; it does not derive the name or check that the two spellings match. The one fully-automatic name in the whole system is &lt;code&gt;AzureWebJobsStorage&lt;/code&gt;, injected by &lt;code&gt;AddAzureFunctionsProject&amp;lt;T&amp;gt;()&lt;/code&gt; itself. Every other name is a contract you keep in code.&lt;/p&gt;

&lt;p&gt;The resolution itself keys off execution context, not config files. &lt;code&gt;RunAsEmulator()&lt;/code&gt; (Service Bus, Storage) and the local container (&lt;code&gt;AddRedis&lt;/code&gt;) are what you get when you run the AppHost. Run &lt;code&gt;azd&lt;/code&gt; in publish mode and &lt;code&gt;RunAsEmulator()&lt;/code&gt; becomes a no-op, so the same declaration provisions the real Azure resource. There is no &lt;code&gt;appsettings.Production.json&lt;/code&gt; toggling between them; the decision is whether you're running or publishing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr61x267l9nn9bf7zcpkx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr61x267l9nn9bf7zcpkx.png" alt="One RunAsEmulator declaration resolves two ways: local emulator containers when you run the AppHost, a provisioned Azure namespace when you publish with azd." width="712" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The connection's &lt;em&gt;shape&lt;/em&gt; flips on publish, and this is the part that's easy to miss. Provisioned Azure resources default to identity-based connections, so a published Storage connection is a &lt;code&gt;__serviceUri&lt;/code&gt; and a Service Bus connection is a &lt;code&gt;__fullyQualifiedNamespace&lt;/code&gt;, not a key-bearing string. For the four auto-wired integrations Aspire emits the right suffix for you. For the escape-hatch resources you write that branch. A Tables service is the clean example: it's Storage, but not one of the auto-wired four, so locally the binding reads a connection string and on Azure it needs the identity-based service URI. You switch the env-var suffix on the execution context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddTables&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ledger"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_ServiceBus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-sb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsPublishMode&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"Ledger__serviceUri"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Ledger"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectionStringExpression&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Locally that injects the Azurite table endpoint under the name &lt;code&gt;Ledger&lt;/code&gt;; on publish the name becomes &lt;code&gt;Ledger__serviceUri&lt;/code&gt; pointing at the provisioned account, and the Azure SDK reads managed identity off the suffix. The auto-wired four run this same switch for you; for everything else, this one line is it.&lt;/p&gt;

&lt;p&gt;The default publish target is Azure Container Apps, which is GA; publishing as a real Function App needs &lt;code&gt;Aspire.Hosting.Azure.AppService&lt;/code&gt;, still preview as of May 2026. Part 3 takes the publish path apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three resources, one source of truth
&lt;/h2&gt;

&lt;p&gt;The migration from Part 1 is additive again. The AppHost gains four declarations (a Service Bus namespace and its queue, a second storage resource, a Redis cache) and three &lt;code&gt;WithReference&lt;/code&gt; lines on one new Functions project. The worker gains one &lt;code&gt;AddRedisClient("cache")&lt;/code&gt; call. Those few lines start five containers when you run the AppHost: two Azurite instances (host storage and &lt;code&gt;app-storage&lt;/code&gt;), the Service Bus emulator with its &lt;code&gt;mssql/server:2022-latest&lt;/code&gt; backend, and Redis 8.6. You declared three services; Aspire pulled the images, generated the secrets between them, and wired every connection.&lt;/p&gt;

&lt;p&gt;No connection strings moved into &lt;code&gt;local.settings.json&lt;/code&gt;, because that's the file the whole exercise is removing as a source of truth. Keep &lt;code&gt;FUNCTIONS_WORKER_RUNTIME&lt;/code&gt; in it and let Aspire own the rest; if a value is set in both, Aspire wins. One line is worth deleting on the way out: the Functions template seeds &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; to &lt;code&gt;UseDevelopmentStorage=true&lt;/code&gt;, which starts its own Azurite and can race the one Aspire owns. Remove it and let &lt;code&gt;AddAzureFunctionsProject&amp;lt;T&amp;gt;()&lt;/code&gt; inject host storage instead.&lt;/p&gt;

&lt;p&gt;The honest scope: the Service Bus emulator is dev/test only and runs under Rosetta on Apple Silicon, Redis isn't auto-wired and its trigger extension is Premium-plan only, and identity-based connections on publish need one branch for the resources outside the auto-wired four. None of that is a blocker, but a teammate hits each one eventually, so put them in the README, not the postmortem.&lt;/p&gt;

&lt;p&gt;Part 3 takes this AppHost to Azure: what &lt;code&gt;azd&lt;/code&gt; provisions, why Container Apps is the default target, and what Aspire generates under the hood.&lt;/p&gt;

&lt;p&gt;Of these three, which do you configure by hand today, a Service Bus connection per environment, a second storage account, or a Redis cache, and which one would you move into the AppHost first?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>aspire</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Getting Started with .NET Aspire for Azure Functions</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 29 May 2026 06:35:04 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/getting-started-with-net-aspire-for-azure-functions-2g88</link>
      <guid>https://dev.to/martin_oehlert/getting-started-with-net-aspire-for-azure-functions-2g88</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;.NET Aspire for Azure Functions Developers&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;Prerequisite: &lt;a href="https://dev.to/martin_oehlert/series/38960"&gt;Azure Functions for .NET Developers&lt;/a&gt; (Parts 1-9)&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1: Getting Started with .NET Aspire for Azure Functions&lt;/strong&gt; &lt;em&gt;(you are here)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 2: Azure Services as Aspire Resources: Service Bus, Storage, and Redis &lt;em&gt;(coming)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 3: Deploying .NET Aspire Apps to Azure: AZD, ACA, and What Aspire Generates &lt;em&gt;(coming)&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;A new developer joins, hits F5, and the Function fails on startup because their &lt;code&gt;local.settings.json&lt;/code&gt; names the storage emulator differently from yours. The question isn't "what should they have typed" but "why is the configuration source of truth a per-machine JSON file in the first place." The &lt;a href="https://learn.microsoft.com/dotnet/aspire/get-started/aspire-overview" rel="noopener noreferrer"&gt;.NET Aspire&lt;/a&gt; AppHost moves that source of truth into a project you check into source control, so the storage emulator, queues, and the Functions app itself all start from one &lt;code&gt;dotnet run&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The drift surface
&lt;/h2&gt;

&lt;p&gt;If you've ever helped a teammate get the &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/ProjectOrganizationDemo" rel="noopener noreferrer"&gt;ProjectOrganizationDemo&lt;/a&gt; sample running, you've seen the surface. Two Function Apps (&lt;code&gt;OrderProcessor.Http&lt;/code&gt; and &lt;code&gt;OrderProcessor.Queue&lt;/code&gt;) share one core library. Each app ships a &lt;code&gt;local.settings.json.example&lt;/code&gt; with two values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"IsEncrypted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Values"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AzureWebJobsStorage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UseDevelopmentStorage=true"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"FUNCTIONS_WORKER_RUNTIME"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dotnet-isolated"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To run the sample end to end you start three processes: Azurite for the emulator, &lt;code&gt;func start&lt;/code&gt; on the HTTP app, &lt;code&gt;func start&lt;/code&gt; on the queue worker. Each developer rebuilds the same configuration on their machine. The two &lt;code&gt;local.settings.json&lt;/code&gt; files are git-ignored, so the source of truth is whatever lives in two text files on every developer's laptop. Multiply by the number of joiners and the time spent on "why isn't my function starting" stops being a one-off.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;[QueueTrigger("orders")] OrderMessage message&lt;/code&gt; attribute in &lt;code&gt;ProcessOrderFunction.cs&lt;/code&gt; doesn't name a &lt;code&gt;Connection&lt;/code&gt;. It falls back to &lt;code&gt;AzureWebJobsStorage&lt;/code&gt;, which is the connection string both apps duplicate. Every storage account, queue, and cache the Functions runtime needs has to be present, by name, in the JSON file the developer remembers to keep up to date.&lt;/p&gt;

&lt;p&gt;The AppHost isn't here to make &lt;code&gt;local.settings.json&lt;/code&gt; shorter. It's here to make it stop being the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Aspire, and why for Functions
&lt;/h2&gt;

&lt;p&gt;A plain ASP.NET API is one &lt;code&gt;dotnet run&lt;/code&gt;. A Functions app isn't. Before any of your business logic executes it needs three things that aren't your code: the &lt;code&gt;func&lt;/code&gt; host (the worker is launched behind it, not as a bare executable), an emulator standing in for &lt;code&gt;AzureWebJobsStorage&lt;/code&gt;, and every trigger's connection resolved by name out of configuration. For a single web service that overhead barely registers. For the two-worker sample above it's the entire local-dev surface, and it's exactly what &lt;a href="https://learn.microsoft.com/dotnet/aspire/get-started/aspire-overview" rel="noopener noreferrer"&gt;.NET Aspire&lt;/a&gt; is built to absorb. Aspire is three things at once: an orchestration model (the AppHost), a set of typed integration packages, and a local dashboard for logs and traces. The orchestration is the part that earns its place here.&lt;/p&gt;

&lt;p&gt;The honest comparison is Aspire against the two things teams already reach for, not against nothing. The first is a shell of terminals. Today you open three: Azurite in one, &lt;code&gt;func start&lt;/code&gt; on the HTTP app in a second, &lt;code&gt;func start&lt;/code&gt; on the queue worker in a third, started in that order because the workers need the emulator already listening. Three log streams in three windows, three &lt;code&gt;local.settings.json&lt;/code&gt; files feeding them, and no view of a message as it crosses from the HTTP app into the queue worker. One &lt;code&gt;dotnet run&lt;/code&gt; on the AppHost replaces all three processes, and because both workers report to the same dashboard you get the thing the three-terminal setup structurally can't produce: a single trace that follows one &lt;code&gt;POST /api/orders&lt;/code&gt; through the HTTP app, across the queue, and into the worker that dequeues it.&lt;/p&gt;

&lt;p&gt;The second is &lt;code&gt;docker-compose&lt;/code&gt;. It can model the same set: an Azurite service, two Function containers, a shared network. What it can't do is stay in .NET. Each Function project needs a Dockerfile and an image rebuild (or a mounted volume) on every change; connection strings live as literal strings in YAML or an &lt;code&gt;.env&lt;/code&gt; file, the same drift surface in a different format; service wiring is container DNS, which has nothing to do with the &lt;code&gt;Connection&lt;/code&gt; name the Functions runtime actually resolves; and the dashboard, traces, and health checks aren't part of the deal. The AppHost is a .NET project that references your Functions projects directly. The resource graph is C# the compiler checks, the connection a worker reads is computed from a container Aspire owns, and the same description is what later drives deployment. You trade a YAML file the build can't verify for a project it can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AppHost as composition root
&lt;/h2&gt;

&lt;p&gt;The Aspire AppHost is a separate .NET project that boots your distributed app. It declares the resources (storage accounts, queues, Service Bus, your Functions projects) and wires them together. One &lt;code&gt;dotnet run&lt;/code&gt; on the AppHost starts the whole set.&lt;/p&gt;

&lt;p&gt;You don't install a workload. Aspire dropped the &lt;code&gt;dotnet workload install aspire&lt;/code&gt; step in 9.0 and hasn't brought it back in 13.x. You install the project templates once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new &lt;span class="nb"&gt;install &lt;/span&gt;Aspire.ProjectTemplates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two templates you need are &lt;code&gt;aspire-apphost&lt;/code&gt; (the orchestration project) and &lt;code&gt;aspire-servicedefaults&lt;/code&gt; (a class library with the OpenTelemetry and health-check wiring you call from your Functions project). The AppHost does the orchestration; the service-defaults library is the one line of worker code that routes telemetry to the dashboard, and a later section wires it in.&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;dotnet new aspire-apphost -n AspireDemo.AppHost&lt;/code&gt; gives you a project file with no &lt;code&gt;Microsoft.NET.Sdk&lt;/code&gt; base and the Aspire SDK pinned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Project&lt;/span&gt; &lt;span class="na"&gt;Sdk=&lt;/span&gt;&lt;span class="s"&gt;"Aspire.AppHost.Sdk/13.3.5"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;PropertyGroup&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;OutputType&amp;gt;&lt;/span&gt;Exe&lt;span class="nt"&gt;&amp;lt;/OutputType&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;TargetFramework&amp;gt;&lt;/span&gt;net10.0&lt;span class="nt"&gt;&amp;lt;/TargetFramework&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;IsAspireHost&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/IsAspireHost&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;ItemGroup&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"Aspire.Hosting.Azure.Functions"&lt;/span&gt; &lt;span class="na"&gt;Version=&lt;/span&gt;&lt;span class="s"&gt;"13.3.5"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/ItemGroup&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Project&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three notes. First, Aspire's project SDK replaces the .NET SDK base. There's no &lt;code&gt;Microsoft.NET.Sdk&lt;/code&gt; row; the &lt;code&gt;Aspire.AppHost.Sdk&lt;/code&gt; brings the build targets. Second, the only package reference you need is &lt;a href="https://www.nuget.org/packages/Aspire.Hosting.Azure.Functions" rel="noopener noreferrer"&gt;&lt;code&gt;Aspire.Hosting.Azure.Functions&lt;/code&gt;&lt;/a&gt;. The AppHost runtime pieces come transitively from the SDK. Third, &lt;code&gt;IsAspireHost&lt;/code&gt; is what marks this project for the source generator that produces the strongly-typed &lt;code&gt;Projects.*&lt;/code&gt; references you use in the next section. (In the companion sample the committed csproj is shorter still: central package management drops the explicit &lt;code&gt;Version&lt;/code&gt;, and a &lt;code&gt;Directory.Build.props&lt;/code&gt; supplies the &lt;code&gt;TargetFramework&lt;/code&gt;, so what's left is the SDK line, &lt;code&gt;IsAspireHost&lt;/code&gt;, and one versionless &lt;code&gt;PackageReference&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;The entry point file is &lt;code&gt;AppHost.cs&lt;/code&gt; (the AppHost's entry point, not &lt;code&gt;Program.cs&lt;/code&gt;). The generated default is two lines:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;That's the empty composition. Adding the Functions project is one more line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Functions app as an Aspire resource
&lt;/h2&gt;

&lt;p&gt;The API is &lt;code&gt;AddAzureFunctionsProject&amp;lt;TProject&amp;gt;(name)&lt;/code&gt;. The generic parameter is the strongly-typed project reference (Aspire's source generator produces it once the project is referenced from the AppHost). The string is the name that shows up on the dashboard:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_Queue&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-queue"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Don't use the generic &lt;code&gt;AddProject&amp;lt;T&amp;gt;&lt;/code&gt; for a Functions project. The &lt;a href="https://learn.microsoft.com/azure/azure-functions/dotnet-aspire-integration" rel="noopener noreferrer"&gt;Microsoft Learn integration page&lt;/a&gt; is blunt about it: the Functions project "can't start properly" if you do. &lt;code&gt;AddAzureFunctionsProject&lt;/code&gt; is the one that knows how to launch the &lt;code&gt;func&lt;/code&gt; host instead of treating the project as a vanilla executable.&lt;/p&gt;

&lt;p&gt;That one line is enough to run a single Functions project. The call provisions host storage (an Azurite emulator container) automatically and sets the &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; environment variable on the worker so existing &lt;code&gt;[QueueTrigger("orders")]&lt;/code&gt; attributes resolve without changes.&lt;/p&gt;

&lt;p&gt;For a multi-project AppHost (the ProjectOrganizationDemo shape: two Functions projects sharing one storage backend), naming the host storage explicitly is the recommended pattern:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;hostStorage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAzureStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"host-storage"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RunAsEmulator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_Http&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-http"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHostStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostStorage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_Queue&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-queue"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHostStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostStorage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WithHostStorage(...)&lt;/code&gt; is GA in &lt;code&gt;Aspire.Hosting.Azure.Functions&lt;/code&gt; 13.1+. It tells Aspire to skip the implicit per-project storage and share the resource you pass in. Locally that collapses to one Azurite container; in publish mode, one storage account.&lt;/p&gt;

&lt;p&gt;Without &lt;code&gt;WithHostStorage&lt;/code&gt;, each &lt;code&gt;AddAzureFunctionsProject&amp;lt;T&amp;gt;&lt;/code&gt; call spins up its own implicit emulator. The Functions still run, but the dashboard shows two storage-emulator rows for what should be one logical concern. For one project that's fine; for two or more, share.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Aspire starts the worker
&lt;/h2&gt;

&lt;p&gt;Aspire never shells out to &lt;code&gt;func start&lt;/code&gt;. The AppHost hands every resource to DCP (the Developer Control Plane), the local orchestrator bundled with the Aspire SDK, and DCP is what actually launches processes and pulls containers. For a Functions project it runs, in effect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotnet run --project OrderProcessor.Queue --no-build --no-launch-profile --port &amp;lt;dcp-assigned&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that otherwise look like trivia follow directly from that command.&lt;/p&gt;

&lt;p&gt;The first is why &lt;code&gt;AddAzureFunctionsProject&amp;lt;T&amp;gt;&lt;/code&gt; exists at all. &lt;code&gt;dotnet run&lt;/code&gt; on a Functions project produces a console executable, not a running Functions host. &lt;code&gt;AddAzureFunctionsProject&amp;lt;T&amp;gt;&lt;/code&gt; is the resource type that knows to boot the isolated worker behind the &lt;code&gt;func&lt;/code&gt; host and inject &lt;code&gt;AzureWebJobsStorage&lt;/code&gt;; the generic &lt;code&gt;AddProject&amp;lt;T&amp;gt;&lt;/code&gt; launches the assembly directly and the worker never starts. That's the concrete reason behind the "can't start properly" warning above.&lt;/p&gt;

&lt;p&gt;The second is &lt;code&gt;--no-build&lt;/code&gt;. DCP runs the project from whatever is already in its output folder; it doesn't compile first. So wherever &lt;code&gt;dotnet build&lt;/code&gt; put the worker DLL is exactly where DCP expects it. If the csproj sets &lt;code&gt;&amp;lt;RuntimeIdentifier&amp;gt;&lt;/code&gt; unconditionally (the "Honest scope" section walks through this), the DLL lands a directory deeper than DCP looks and the worker fails on launch. &lt;code&gt;func start&lt;/code&gt; papered over that; &lt;code&gt;dotnet run --no-build&lt;/code&gt; does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service discovery: two cases
&lt;/h2&gt;

&lt;p&gt;Host storage and user-defined connections behave differently. Host storage is auto-wired. User connections you wire yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 1: host storage.&lt;/strong&gt; &lt;code&gt;AddAzureFunctionsProject&amp;lt;T&amp;gt;(...)&lt;/code&gt; injects the env var &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; into the worker process, set to the full Azurite connection string for local runs (the literal account-key form, not &lt;code&gt;UseDevelopmentStorage=true&lt;/code&gt;). The worker reads that env var directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessOrder&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;QueueTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"AzureWebJobsStorage"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// worker code unchanged from the non-Aspire setup&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trigger attribute didn't change. The &lt;code&gt;Connection = "AzureWebJobsStorage"&lt;/code&gt; string still names an env var; the AppHost is just the thing setting it now. Trigger attributes that omit &lt;code&gt;Connection&lt;/code&gt; entirely also fall back to &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; and resolve the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 2: user-defined resources.&lt;/strong&gt; A queue that belongs to your application (not to the host) gets a name you choose, and the trigger has to match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ordersStorage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAzureStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-storage"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RunAsEmulator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ordersQueue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ordersStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddQueues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"queues"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_Queue&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-queue"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ordersQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"OrdersConnection"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second argument to &lt;code&gt;WithReference&lt;/code&gt; is the literal env var the Functions runtime resolves. The trigger then names that same string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessOrder&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;QueueTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"OrdersConnection"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contract between AppHost and worker is exactly that string. No magic mapping happens; a resource called &lt;code&gt;orders-storage&lt;/code&gt; is not auto-injected as &lt;code&gt;OrdersConnection&lt;/code&gt;. If the names don't match, the Functions runtime can't find the connection and the trigger fails to start.&lt;/p&gt;

&lt;p&gt;Auto-wiring (where the second argument to &lt;code&gt;WithReference&lt;/code&gt; is the only thing you need) covers four integrations: Azure Blob Storage, Azure Queue Storage, Azure Event Hubs, Azure Service Bus. For other resources, you wire one env var manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cache"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddAzureFunctionsProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderProcessor_Http&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-http"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"RedisConnection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectionStringExpression&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One extra line per resource. Not free, but localised.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the worker actually receives
&lt;/h2&gt;

&lt;p&gt;The claim that the AppHost is "just the thing setting the env var now" is checkable. Open the dashboard, select the &lt;code&gt;orders-queue&lt;/code&gt; row, and look at its environment variables (the Resources page lists them per resource). For the shared-host-storage setup above, &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; is set to the full Azurite connection string, account key and all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AzureWebJobsStorage=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8.../...;BlobEndpoint=http://127.0.0.1:&amp;lt;port&amp;gt;/devstoreaccount1;QueueEndpoint=http://127.0.0.1:&amp;lt;port&amp;gt;/devstoreaccount1;TableEndpoint=http://127.0.0.1:&amp;lt;port&amp;gt;/devstoreaccount1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what it is not: &lt;code&gt;UseDevelopmentStorage=true&lt;/code&gt;. Aspire resolves the running Azurite container's mapped ports and writes the literal account-key form. That is the clearest single sign the connection string no longer lives in a file you maintain; the worker reads a value the AppHost computed at startup from a container it owns.&lt;/p&gt;

&lt;p&gt;A second injected variable is the one that makes host logs show up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AzureFunctionsJobHost__telemetryMode=OpenTelemetry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the configuration equivalent of setting &lt;code&gt;"telemetryMode": "OpenTelemetry"&lt;/code&gt; in &lt;code&gt;host.json&lt;/code&gt;, and Aspire sets it on the worker for you. It's why the Functions host's own logs reach the dashboard with no &lt;code&gt;host.json&lt;/code&gt; edit.&lt;/p&gt;

&lt;p&gt;Aspire also writes a set of hierarchical keys for its typed storage clients (&lt;code&gt;Aspire__Azure__Storage__Queues__AzureWebJobsStorage__ConnectionString&lt;/code&gt; and siblings). The default Functions binding extensions ignore them; they matter only if the Functions project adds the matching &lt;code&gt;Aspire.Azure.Storage.*&lt;/code&gt; client package. You can leave them unread.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one change inside the Functions project
&lt;/h2&gt;

&lt;p&gt;Everything so far lived in the AppHost; the worker code was untouched. The dashboard's logs, traces, and metrics are the exception. They need one line in each Functions project's &lt;code&gt;Program.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

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

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddServiceDefaults()&lt;/code&gt; comes from the &lt;code&gt;aspire-servicedefaults&lt;/code&gt; project, a small class library you reference from each Functions project. It registers the OpenTelemetry exporter that targets the dashboard, plus health checks and HttpClient resilience. The call has to land before &lt;code&gt;Build()&lt;/code&gt;, and the &lt;a href="https://learn.microsoft.com/azure/azure-functions/dotnet-aspire-integration" rel="noopener noreferrer"&gt;integration doc&lt;/a&gt; is specific that it goes on the &lt;code&gt;IHostApplicationBuilder&lt;/code&gt; that &lt;code&gt;FunctionsApplication.CreateBuilder&lt;/code&gt; returns, not the older &lt;code&gt;HostBuilder().ConfigureFunctionsWorkerDefaults()&lt;/code&gt; style.&lt;/p&gt;

&lt;p&gt;It's a no-op when you run the project standalone with &lt;code&gt;func start&lt;/code&gt;. The OTLP exporter only activates when &lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/code&gt; is set, which the AppHost does and a bare &lt;code&gt;func start&lt;/code&gt; does not. So the same code runs both ways: under Aspire it lights up the dashboard, outside it stays quiet.&lt;/p&gt;

&lt;p&gt;This is also where the App Insights cleanup belongs. If &lt;code&gt;Program.cs&lt;/code&gt; still calls &lt;code&gt;AddApplicationInsightsTelemetryWorkerService()&lt;/code&gt;, drop it (the "Honest scope" section explains why) and let &lt;code&gt;AddServiceDefaults&lt;/code&gt; own telemetry.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dashboard
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dotnet run --project AspireDemo.AppHost&lt;/code&gt; boots everything and prints a line that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Login to the dashboard at https://localhost:17281/login?t=&amp;lt;token&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The port is randomised by &lt;code&gt;Properties/launchSettings.json&lt;/code&gt; (pin it there if you want a stable URL); the token is regenerated every run and persists as a browser cookie for three days. From Visual Studio or VS Code with the Aspire extension the browser opens automatically; from the CLI you ctrl-click the URL.&lt;/p&gt;

&lt;p&gt;The dashboard has four pages worth knowing about for a Functions app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resources.&lt;/strong&gt; Every resource you declared (your Functions projects, the host storage emulator, any queues or Service Bus namespaces) shows up as a row. Each row has a state (Running, Starting, Failed), endpoints, and a per-resource log tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs.&lt;/strong&gt; Structured logs from the worker flow to the dashboard because of the &lt;code&gt;AddServiceDefaults()&lt;/code&gt; call from the previous section, combined with the auto-injected &lt;code&gt;AzureFunctionsJobHost__telemetryMode=OpenTelemetry&lt;/code&gt;. No &lt;code&gt;host.json&lt;/code&gt; edits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traces.&lt;/strong&gt; ASP.NET Core, HttpClient, and the Azure SDK &lt;code&gt;Azure.*&lt;/code&gt; ActivitySources participate by default, so calls that cross those boundaries link up into one span tree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics.&lt;/strong&gt; &lt;code&gt;Microsoft.AspNetCore.*&lt;/code&gt;, &lt;code&gt;System.Net.Http&lt;/code&gt;, and the .NET runtime meters are wired in by &lt;code&gt;AddServiceDefaults&lt;/code&gt;. Per-invocation Functions metrics under a &lt;code&gt;Microsoft.Azure.Functions.*&lt;/code&gt; meter still require explicit wiring in the worker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tracing is the page that pays off the multi-project setup. The sample gives &lt;code&gt;CreateOrderFunction&lt;/code&gt; in &lt;code&gt;orders-http&lt;/code&gt; a &lt;code&gt;[QueueOutput("orders")]&lt;/code&gt; binding and &lt;code&gt;ProcessOrderFunction&lt;/code&gt; in &lt;code&gt;orders-queue&lt;/code&gt; the matching &lt;code&gt;[QueueTrigger("orders")]&lt;/code&gt;. A single &lt;code&gt;POST /api/orders&lt;/code&gt; then produces one connected trace that spans both apps: the inbound HTTP server span in &lt;code&gt;orders-http&lt;/code&gt;, the Azure Storage Queue send span beneath it, and the queue-trigger span in &lt;code&gt;orders-queue&lt;/code&gt; that fires when the message is dequeued. Two processes, one span tree, because the Azure SDK propagates W3C trace context through the queue message. That cross-process link is the thing three separate &lt;code&gt;func start&lt;/code&gt; terminals could never show you.&lt;/p&gt;

&lt;p&gt;The dashboard isn't a replacement for production &lt;a href="https://learn.microsoft.com/azure/azure-monitor/app/app-insights-overview" rel="noopener noreferrer"&gt;Application Insights&lt;/a&gt;. It's the same OTLP data your Functions app would send to App Insights in production, except routed to a local UI you see in the first ten seconds of &lt;code&gt;dotnet run&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after
&lt;/h2&gt;

&lt;p&gt;The migration from the original sample to the AppHost is almost entirely additive. You add two projects (the AppHost and the small service-defaults library), share the host storage, and trim the &lt;code&gt;local.settings.json&lt;/code&gt; files. The only edit inside the existing Functions projects is the single &lt;code&gt;AddServiceDefaults()&lt;/code&gt; line from earlier, plus a reference to the service-defaults project. The DX shift:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdfv7mo6vd5ptwqo52rsq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdfv7mo6vd5ptwqo52rsq.png" alt="Before and after: the developer-experience shift from per-machine local.settings.json to a shared Aspire AppHost" width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The "untracked config files" row is the one most teams underestimate. The file doesn't disappear; it shrinks to a single setting (&lt;code&gt;FUNCTIONS_WORKER_RUNTIME&lt;/code&gt;). Removing it entirely is a future enhancement on the Functions team's roadmap; today's GA story has you keep it minimal but not absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest scope
&lt;/h2&gt;

&lt;p&gt;A few caveats that don't fit the marketing slide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trigger auto-wiring is four integrations.&lt;/strong&gt; Blob, Queue, Event Hubs, Service Bus. Anything else (Cosmos DB, Redis, SignalR, SQL, custom HTTP services) needs the &lt;code&gt;WithEnvironment("Name", resource.ConnectionStringExpression)&lt;/code&gt; form. One extra line per resource. Real, but bounded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;local.settings.json&lt;/code&gt; must lose the &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; line.&lt;/strong&gt; Leaving &lt;code&gt;"AzureWebJobsStorage": "UseDevelopmentStorage=true"&lt;/code&gt; in the file (the template default) makes the Functions host try to spin up against Azurite directly, while Aspire is also running its own Azurite container. You end up with two emulators and a port conflict. Trim the file to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"IsEncrypted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Values"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"FUNCTIONS_WORKER_RUNTIME"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dotnet-isolated"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Remove direct App Insights wiring from Functions.&lt;/strong&gt; If your &lt;code&gt;Program.cs&lt;/code&gt; calls &lt;code&gt;AddApplicationInsightsTelemetryWorkerService()&lt;/code&gt;, drop it and rely on &lt;code&gt;AddServiceDefaults&lt;/code&gt; (which configures the OTLP exporter the dashboard reads). &lt;code&gt;Microsoft.ApplicationInsights.WorkerService 2.22.0&lt;/code&gt; had a runtime conflict against Aspire that was fixed in 2.23.0; if you can't upgrade, the safer move is to remove the App Insights worker package and route everything through OpenTelemetry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unconditional &lt;code&gt;&amp;lt;RuntimeIdentifier&amp;gt;&lt;/code&gt; breaks &lt;code&gt;dotnet run --no-build&lt;/code&gt;.&lt;/strong&gt; If your Functions csproj has &lt;code&gt;&amp;lt;RuntimeIdentifier&amp;gt;linux-x64&amp;lt;/RuntimeIdentifier&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;PublishReadyToRun&amp;gt;true&amp;lt;/PublishReadyToRun&amp;gt;&lt;/code&gt; set unconditionally (a common copy-paste from a CI sample), &lt;code&gt;dotnet build&lt;/code&gt; puts the worker DLL under &lt;code&gt;bin/Debug/net10.0/linux-x64/&lt;/code&gt; instead of &lt;code&gt;bin/Debug/net10.0/&lt;/code&gt;. As the "How Aspire starts the worker" section explained, DCP loads from that path without rebuilding, so on macOS arm64 the worker crashes immediately. Scope both properties to Release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;PropertyGroup&lt;/span&gt; &lt;span class="na"&gt;Condition=&lt;/span&gt;&lt;span class="s"&gt;"'$(Configuration)' == 'Release'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;RuntimeIdentifier&amp;gt;&lt;/span&gt;linux-x64&lt;span class="nt"&gt;&amp;lt;/RuntimeIdentifier&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;PublishReadyToRun&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/PublishReadyToRun&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Publishing as a real &lt;code&gt;functionapp,linux&lt;/code&gt; App Service is preview.&lt;/strong&gt; The default Aspire publish target is Azure Container Apps, which is GA. If your team requires App Service plans (Consumption, Premium, Dedicated), &lt;code&gt;Aspire.Hosting.Azure.AppService&lt;/code&gt; is the package and it's still &lt;code&gt;13.x-preview&lt;/code&gt; as of May 2026. Part 3 of this series covers publish targets and the trade-offs.&lt;/p&gt;

&lt;p&gt;The local-dev story this article sells (AppHost, dashboard, Azurite + Service Bus emulators, structured logs, traces, the host-storage and user-trigger patterns) is GA in Aspire 13.1+. Nothing in the sections above is preview.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still copying local.settings.json.example, or moved to an AppHost?
&lt;/h2&gt;

&lt;p&gt;If you've migrated, what's the friction point in the workflow you didn't expect? If you haven't, what's the blocker: the one extra &lt;code&gt;WithEnvironment&lt;/code&gt; line per non-auto-wired resource, the publish path you'd need (Container Apps versus App Service), or the trim-not-delete shape of &lt;code&gt;local.settings.json&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;The companion sample for this article lives at &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/AspireDemo" rel="noopener noreferrer"&gt;&lt;code&gt;AspireDemo/&lt;/code&gt; in azure-functions-samples&lt;/a&gt;. It reuses the existing &lt;code&gt;ProjectOrganizationDemo&lt;/code&gt; projects, adds the AppHost and a service-defaults library above them, and trims both &lt;code&gt;local.settings.json&lt;/code&gt; files. The migration is nearly additive: the two Functions projects are project references from the AppHost, and the only worker-code change is one &lt;code&gt;AddServiceDefaults()&lt;/code&gt; line each, the line that routes their telemetry to the dashboard.&lt;/p&gt;

&lt;p&gt;Part 2 of the series takes the same AppHost and adds Service Bus, additional storage, and a Redis cache as Aspire resources. Part 3 walks through the publish path with &lt;code&gt;azd&lt;/code&gt; and Container Apps, and shows what Aspire generates under the hood.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>aspire</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Preparing for Migration: Decoupling Your Function Logic</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 22 May 2026 05:48:43 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/preparing-for-migration-decoupling-your-function-logic-2clh</link>
      <guid>https://dev.to/martin_oehlert/preparing-for-migration-decoupling-your-function-logic-2clh</guid>
      <description>&lt;p&gt;When does pushing logic out of the Function method actually pay off? The day the Consumption-plan batch runs past 10 minutes, the host kills the worker, and the same queue message is delivered again from the top. The trigger binding turns out to be a hosting contract, not a programming model: a queue trigger and a &lt;code&gt;BackgroundService&lt;/code&gt; loop are two different shapes for the same job. Once the workload sits behind an injected service, swapping one shape for the other becomes a &lt;code&gt;Program.cs&lt;/code&gt; change instead of a rewrite, and tests stop needing the Functions host.&lt;/p&gt;

&lt;p&gt;The pattern shows up most clearly in a working sample. The &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/MigrationDemo" rel="noopener noreferrer"&gt;&lt;code&gt;MigrationDemo&lt;/code&gt;&lt;/a&gt; folder from Part 5 ships one workload (&lt;code&gt;Settlement.Core&lt;/code&gt;) and three hosts (Functions, App Service, Container App) that all call into it. Everything below is grounded in that code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trigger as a thin controller
&lt;/h2&gt;

&lt;p&gt;The Function method does three things and nothing else:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Receive the trigger payload. For queue, event hub, and Cosmos triggers the binding deserializes it for you. HTTP triggers do their own &lt;code&gt;ReadFromJsonAsync&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Call an injected service with the deserialized command and a &lt;code&gt;CancellationToken&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Return or forward the result.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything beyond that list (validation past the framework checks, persistence, downstream calls, business rules) belongs to a service. The rule is not "make the Function shorter", it is "make the Function disposable": the same workload has to be callable from somewhere that is not a trigger binding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Before: the trigger doing too much
&lt;/h3&gt;

&lt;p&gt;A naïve settlement function pulls a batch off the queue and processes it inline. Reading raw configuration, branching on the gateway response, sending dead-letter messages, all in the &lt;code&gt;Run&lt;/code&gt; method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="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;SettlementFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;QueueClient&lt;/span&gt; &lt;span class="n"&gt;deadLetterQueue&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;SettlementFunction&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="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;SettlementFunction&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;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;QueueTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"settlement-batches"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"AzureWebJobsStorage"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="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;batch&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;SettlementBatch&lt;/span&gt;&lt;span class="p"&gt;&amp;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;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;"Malformed batch."&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;delayMs&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="nf"&gt;Parse&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;"PER_PAYMENT_DELAY_MS"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"50"&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;failureRate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;double&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="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;"FAILURE_RATE"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"0.02"&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;settled&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;failed&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;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;payment&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Payments&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="nf"&gt;ThrowIfCancellationRequested&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;delayMs&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;hash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;uint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PaymentId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetHashCode&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&lt;/span&gt; &lt;span class="m"&gt;0xFFFF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;65536.0&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;failureRate&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;accepted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;settled&lt;/span&gt;&lt;span class="p"&gt;++;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;failed&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 {PaymentId}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PaymentId&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;deadLetterQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendMessageAsync&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;Serialize&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;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PaymentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"GATEWAY_DECLINED"&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="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;"Batch {BatchId}: settled={Settled}, failed={Failed}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BatchId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failed&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;Three problems are not visible in the file, but they are visible the first time something goes wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The settlement loop only runs inside the Functions worker. A Consumption-plan batch that runs past the &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-scale#timeout" rel="noopener noreferrer"&gt;10-minute default timeout&lt;/a&gt; gets killed, and the same message comes back from scratch.&lt;/li&gt;
&lt;li&gt;Changing the failure-rate threshold means redeploying the Function. Nothing in the loop is testable without spinning up the host.&lt;/li&gt;
&lt;li&gt;Replacing the dead-letter queue with Service Bus is a method rewrite. The branching, the serialization, and the SDK call are tangled at the call site.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  After: the contract in code
&lt;/h3&gt;

&lt;p&gt;Push the loop into &lt;code&gt;IPaymentSettler&lt;/code&gt; and the trigger collapses to its three jobs. From &lt;a href="https://github.com/MO2k4/azure-functions-samples/blob/main/MigrationDemo/Settlement.FunctionApp/SettlementFunction.cs" rel="noopener noreferrer"&gt;&lt;code&gt;Settlement.FunctionApp/SettlementFunction.cs&lt;/code&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;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;SettlementFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;IPaymentSettler&lt;/span&gt; &lt;span class="n"&gt;settler&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;SettlementFunction&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="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;SettlementFunction&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;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;QueueTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"settlement-batches"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"AzureWebJobsStorage"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;SettlementBatch&lt;/span&gt; &lt;span class="n"&gt;batch&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;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;"Function host received batch {BatchId} ({Count} payments)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BatchId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Payments&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;settler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SettleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;progress&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;cancellationToken&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;"Function host completed batch {BatchId}: settled={Settled}, failed={Failed}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BatchId&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;Settled&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;Failed&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;Five statements. The &lt;code&gt;QueueTrigger&lt;/code&gt; binding deserializes the message into a &lt;code&gt;SettlementBatch&lt;/code&gt; POCO before &lt;code&gt;Run&lt;/code&gt; is called; Microsoft Learn confirms this in the &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-bindings-storage-queue-trigger?tabs=isolated-process#usage" rel="noopener noreferrer"&gt;queue trigger usage notes&lt;/a&gt;. The body of the workload sits behind &lt;code&gt;IPaymentSettler.SettleAsync(SettlementBatch, IProgress&amp;lt;SettlementProgress&amp;gt;?, CancellationToken)&lt;/code&gt;. The &lt;code&gt;CancellationToken&lt;/code&gt; propagates through.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does not belong, with evidence from the sample
&lt;/h3&gt;

&lt;p&gt;Each rule below would show up as code in &lt;code&gt;SettlementFunction.cs&lt;/code&gt; if it had been violated. None of them is.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Business rules.&lt;/strong&gt; No validation, no per-payment branching, no calculation in &lt;code&gt;Run&lt;/code&gt;. The accept/reject branch and the rejection log live in &lt;a href="https://github.com/MO2k4/azure-functions-samples/blob/main/MigrationDemo/Settlement.Core/Services/PaymentSettler.cs" rel="noopener noreferrer"&gt;&lt;code&gt;PaymentSettler.SettleAsync&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct Azure SDK calls.&lt;/strong&gt; &lt;code&gt;SettlementFunction.cs&lt;/code&gt; has no &lt;code&gt;using Azure.Storage.*&lt;/code&gt; and never constructs a &lt;code&gt;QueueClient&lt;/code&gt; or &lt;code&gt;BlobClient&lt;/code&gt;. The trigger reaches the downstream payments network through &lt;code&gt;IPaymentSettler&lt;/code&gt;, which depends on &lt;code&gt;ISettlementGateway&lt;/code&gt;, which speaks the domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FunctionContext&lt;/code&gt; leakage.&lt;/strong&gt; Neither &lt;code&gt;Run&lt;/code&gt; nor &lt;code&gt;IPaymentSettler&lt;/code&gt; declares &lt;code&gt;FunctionContext&lt;/code&gt;. That type ships in &lt;code&gt;Microsoft.Azure.Functions.Worker&lt;/code&gt;; if a service took it as a parameter, that service would not compile in the App Service or Container App project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment-variable reads.&lt;/strong&gt; No &lt;code&gt;Environment.GetEnvironmentVariable&lt;/code&gt; anywhere in &lt;code&gt;Settlement.Core&lt;/code&gt;. Configuration is &lt;code&gt;IOptions&amp;lt;SettlementOptions&amp;gt;&lt;/code&gt; and &lt;code&gt;IOptions&amp;lt;PaymentSettlerOptions&amp;gt;&lt;/code&gt;, bound identically across all three hosts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sink-specific logging.&lt;/strong&gt; &lt;code&gt;SettlementFunction&lt;/code&gt; takes &lt;code&gt;ILogger&amp;lt;SettlementFunction&amp;gt;&lt;/code&gt;. No &lt;code&gt;TelemetryClient&lt;/code&gt;, no &lt;code&gt;FunctionContext.GetLogger&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why thinness matters when the host changes
&lt;/h3&gt;

&lt;p&gt;Three things in the after-snippet are bound to &lt;code&gt;Microsoft.Azure.Functions.Worker&lt;/code&gt;: the &lt;code&gt;[Function]&lt;/code&gt; attribute, the &lt;code&gt;[QueueTrigger]&lt;/code&gt; attribute, and the dispatch contract that says the worker calls &lt;code&gt;Run&lt;/code&gt;. When the host changes, those three things get rewritten. Whatever sits below the &lt;code&gt;settler.SettleAsync(...)&lt;/code&gt; call site survives.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;MigrationDemo&lt;/code&gt; sample makes that claim measurable. The same &lt;code&gt;IPaymentSettler.SettleAsync&lt;/code&gt; call appears in three host projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Settlement.FunctionApp/SettlementFunction.cs&lt;/code&gt;: queue trigger, five statements.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Settlement.AppService/Services/SettlementWorker.cs&lt;/code&gt;: a &lt;code&gt;BackgroundService&lt;/code&gt; polling the same queue, with an &lt;code&gt;IProgress&amp;lt;SettlementProgress&amp;gt;&lt;/code&gt; reporter feeding a &lt;code&gt;/status&lt;/code&gt; endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Settlement.ContainerApp/SettlementWorker.cs&lt;/code&gt;: the same polling loop with no HTTP host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Identical call signature, identical service, identical cancellation propagation. The diff between the three hosts is the activation surface and the composition root. The workload itself does not move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Abstracting Azure SDK dependencies
&lt;/h2&gt;

&lt;p&gt;The first temptation when "separating logic" is to wrap every SDK type. &lt;code&gt;IBlobStore&lt;/code&gt; around &lt;code&gt;BlobClient&lt;/code&gt;. &lt;code&gt;IQueueClientWrapper&lt;/code&gt; around &lt;code&gt;QueueClient&lt;/code&gt;. &lt;code&gt;IServiceBusSenderWrapper&lt;/code&gt; around &lt;code&gt;ServiceBusSender&lt;/code&gt;. Each wrapper has the same surface as the SDK, just with the namespace renamed. The indirection doubles, the testable surface does not change, and now there are two places to update when the SDK adds a method.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MigrationDemo&lt;/code&gt; cuts in a different place. It abstracts the &lt;em&gt;workload shape&lt;/em&gt;, not the &lt;em&gt;transport&lt;/em&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;interface&lt;/span&gt; &lt;span class="nc"&gt;ISettlementGateway&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;SettlementResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;SubmitAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Payment&lt;/span&gt; &lt;span class="n"&gt;payment&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;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IPaymentSettler&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;SettlementProgress&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;SettleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;SettlementBatch&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;IProgress&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SettlementProgress&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt; &lt;span class="n"&gt;progress&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both interfaces speak the domain. &lt;code&gt;Payment&lt;/code&gt; and &lt;code&gt;SettlementBatch&lt;/code&gt; are records in &lt;code&gt;Settlement.Core/Models/&lt;/code&gt;. The downstream payments network is an &lt;code&gt;ISettlementGateway&lt;/code&gt;; the demo wires &lt;code&gt;FakeSettlementGateway&lt;/code&gt; (deterministic, fast, no network), and production swaps in a typed &lt;code&gt;HttpClient&lt;/code&gt; against the real provider. The workload entrypoint is &lt;code&gt;IPaymentSettler&lt;/code&gt;. One method each. No &lt;code&gt;BlobClient&lt;/code&gt;, no &lt;code&gt;QueueClient&lt;/code&gt;, no &lt;code&gt;ServiceBusSender&lt;/code&gt; anywhere in the file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Infrastructure clients live above &lt;code&gt;Settlement.Core&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The SDK clients still exist; they just live above the workload library. The App Service and Container App hosts both drain the same queue, and both register &lt;code&gt;QueueServiceClient&lt;/code&gt; via &lt;a href="https://learn.microsoft.com/dotnet/azure/sdk/dependency-injection" rel="noopener noreferrer"&gt;&lt;code&gt;Microsoft.Extensions.Azure&lt;/code&gt;&lt;/a&gt;. From &lt;code&gt;Settlement.AppService/Program.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAzureClients&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clientBuilder&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;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queueConnection&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;clientBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddQueueServiceClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queueConnection&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queueServiceUri&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;clientBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddQueueServiceClient&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="n"&gt;queueServiceUri&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;clientBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseCredential&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;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;else&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;"Queue:ConnectionString or Queue:ServiceUri must be configured."&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 branches, one shared registration. The &lt;code&gt;ConnectionString&lt;/code&gt; branch is for local development against Azurite; the &lt;code&gt;ServiceUri&lt;/code&gt; branch uses &lt;a href="https://learn.microsoft.com/dotnet/azure/sdk/authentication/credential-chains#defaultazurecredential-overview" rel="noopener noreferrer"&gt;&lt;code&gt;DefaultAzureCredential&lt;/code&gt;&lt;/a&gt; so the production host authenticates with managed identity. The choice is config-driven, so the composition root does not change between dev and prod.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;QueueServiceClient&lt;/code&gt; is the per-account singleton. The per-queue &lt;code&gt;QueueClient&lt;/code&gt; is derived from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;sp&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;service&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;QueueServiceClient&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;name&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;IOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;QueueOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;().&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueueName&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;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetQueueClient&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SettlementWorker&lt;/code&gt; keeps its existing &lt;code&gt;QueueClient&lt;/code&gt; constructor parameter; only the wiring above changed. The Container App host has the same block, with one config object difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Functions host does not call &lt;code&gt;AddAzureClients&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Settlement.FunctionApp/Program.cs&lt;/code&gt; skips this whole section. It does not need to. The &lt;code&gt;[QueueTrigger("settlement-batches", Connection = "AzureWebJobsStorage")]&lt;/code&gt; attribute reads the storage connection from the host's &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; setting and manages the client itself. To run the Function host on managed identity instead of a connection string, set &lt;code&gt;AzureWebJobsStorage__queueServiceUri&lt;/code&gt; on the app's configuration and grant the function app's identity the &lt;strong&gt;Storage Queue Data Contributor&lt;/strong&gt; role on the storage account. Microsoft Learn documents the suffix pattern in &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-reference?tabs=blob#configure-an-identity-based-connection" rel="noopener noreferrer"&gt;identity-based connections for Functions&lt;/a&gt;. That is a hosting change, not a code change.&lt;/p&gt;

&lt;p&gt;Keep the asymmetry in mind when reading the three &lt;code&gt;Program.cs&lt;/code&gt; files side by side. The App Service and Container App hosts manage the queue client themselves because their &lt;code&gt;BackgroundService&lt;/code&gt; is the activation surface. The Function host hands that job to the binding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building portable service classes
&lt;/h2&gt;

&lt;p&gt;A service class is portable when three rules hold. Each one rules out a specific failure I have watched bite during migration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 1: zero references to &lt;code&gt;Microsoft.Azure.Functions.*&lt;/code&gt; in the project file
&lt;/h3&gt;

&lt;p&gt;The portability claim has to survive &lt;code&gt;grep&lt;/code&gt;. In &lt;code&gt;MigrationDemo&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;$ grep -r "Microsoft.Azure.Functions" Settlement.Core/
(no matches)

$ dotnet list package --include-transitive --project Settlement.Core/Settlement.Core.csproj | grep -i Functions
(no matches)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Settlement.Core.csproj&lt;/code&gt; declares three direct references, all &lt;code&gt;Microsoft.Extensions.*&lt;/code&gt;: DI abstractions, logging abstractions, options. If anything in that list grows a transitive &lt;code&gt;Microsoft.Azure.Functions.*&lt;/code&gt; dependency, the library is no longer host-agnostic and the migration story stops working. Make this check part of CI so it does not regress quietly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 2: configuration via &lt;code&gt;IOptions&amp;lt;T&amp;gt;&lt;/code&gt; with validated binding
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;PaymentSettler&lt;/code&gt; reads its only knob through &lt;code&gt;PaymentSettlerOptions&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PaymentSettlerOptions&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;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;SectionName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"PaymentSettler"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Range&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="m"&gt;100_000&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;ProgressReportInterval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="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;Every host binds the same options the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PaymentSettlerOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PaymentSettlerOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SectionName&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidateDataAnnotations&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidateOnStart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What changes between hosts is the &lt;em&gt;source&lt;/em&gt; of the value, not the &lt;em&gt;shape&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Functions: &lt;code&gt;PaymentSettler__ProgressReportInterval&lt;/code&gt; as an app setting, or &lt;code&gt;Values:PaymentSettler:ProgressReportInterval&lt;/code&gt; in &lt;code&gt;local.settings.json&lt;/code&gt;. The double underscore is the cross-platform &lt;a href="https://learn.microsoft.com/dotnet/core/extensions/configuration-providers#environment-variable-configuration-provider" rel="noopener noreferrer"&gt;environment-variable hierarchy delimiter&lt;/a&gt; that maps to &lt;code&gt;:&lt;/code&gt; in the configuration tree.&lt;/li&gt;
&lt;li&gt;App Service: nested JSON in &lt;code&gt;appsettings.Development.json&lt;/code&gt; (&lt;code&gt;{ "PaymentSettler": { "ProgressReportInterval": 50 } }&lt;/code&gt;) or the same env-var shape in the portal.&lt;/li&gt;
&lt;li&gt;Container App: nested JSON plus the option to inject &lt;code&gt;PaymentSettler__ProgressReportInterval&lt;/code&gt; at runtime, often via a Container Apps secret.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;IConfiguration&lt;/code&gt; collapses all three sources into one binding call. &lt;code&gt;PaymentSettler&lt;/code&gt; only ever sees &lt;code&gt;IOptions&amp;lt;PaymentSettlerOptions&amp;gt;&lt;/code&gt;. Validation runs on startup, so a missing or out-of-range value fails the host before the first message is processed instead of crashing one batch in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 3: logging via &lt;code&gt;ILogger&amp;lt;T&amp;gt;&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;PaymentSettler&lt;/code&gt; takes &lt;code&gt;ILogger&amp;lt;PaymentSettler&amp;gt;&lt;/code&gt; in its primary constructor. Nothing else. No &lt;code&gt;TelemetryClient&lt;/code&gt;, no &lt;code&gt;FunctionContext.GetLogger&lt;/code&gt;, no &lt;code&gt;Console.WriteLine&lt;/code&gt;. The sink (Application Insights, OpenTelemetry, console) is wired in each host's &lt;code&gt;Program.cs&lt;/code&gt;; the workload never sees which one is registered.&lt;/p&gt;

&lt;p&gt;The moment a service starts using &lt;code&gt;TelemetryClient&lt;/code&gt; directly, it has a hard dependency on the Application Insights SDK. The Container App host might not register that. The migration story turns from "swap the host" into "audit every logging call site", which is exactly the rewrite the decoupling work was supposed to avoid.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;BackgroundService&lt;/code&gt; lifetime trap
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Settlement.Core/Services/ServiceCollectionExtensions.cs&lt;/code&gt; registers &lt;code&gt;IPaymentSettler&lt;/code&gt; as a singleton:&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;IServiceCollection&lt;/span&gt; &lt;span class="nf"&gt;AddSettlementCore&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;IServiceCollection&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TryAddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IPaymentSettler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PaymentSettler&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;services&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 works because &lt;code&gt;PaymentSettler&lt;/code&gt; is stateless past its &lt;code&gt;IOptions&amp;lt;&amp;gt;&lt;/code&gt; snapshot. The minute a real workload grows a scoped dependency (a &lt;code&gt;DbContext&lt;/code&gt;, a tenant-bound &lt;code&gt;HttpClient&lt;/code&gt;), the lifetime story diverges across hosts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The isolated Functions worker creates a scope per invocation. A scoped &lt;code&gt;DbContext&lt;/code&gt; gets a fresh instance per message.&lt;/li&gt;
&lt;li&gt;ASP.NET creates a scope per HTTP request, but a &lt;code&gt;BackgroundService&lt;/code&gt; runs in the &lt;strong&gt;root scope&lt;/strong&gt;. Injecting a scoped service into a &lt;code&gt;BackgroundService&lt;/code&gt; constructor throws on startup. Microsoft Learn flags the constraint in the &lt;a href="https://learn.microsoft.com/aspnet/core/fundamentals/host/hosted-services" rel="noopener noreferrer"&gt;hosted-services guidance&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The Container App host hits the same root-scope rule because its activation surface is also a &lt;code&gt;BackgroundService&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is to inject &lt;code&gt;IServiceScopeFactory&lt;/code&gt; into the &lt;code&gt;BackgroundService&lt;/code&gt; and call &lt;code&gt;CreateScope()&lt;/code&gt; per message, then resolve the scoped dependency from the scope. The Function host does not need that wrapping. Flag this before the first scoped dependency lands, or the App Service and Container App hosts will diverge from Functions in a way that only shows up at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing without the Functions runtime
&lt;/h2&gt;

&lt;p&gt;A unit test that spins up the Functions host to verify a discount calculation takes four seconds to start, and goes red every time the worker SDK ships a new version. The discount calculation has nothing to do with the trigger. Push it behind &lt;code&gt;IPaymentSettler&lt;/code&gt; and the test becomes plain xUnit construction against the service class.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PaymentSettler&lt;/code&gt; takes three constructor parameters, all in &lt;code&gt;Microsoft.Extensions.*&lt;/code&gt;. The unit test resolves them by hand:&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;SettleAsync_with_all_accepting_gateway_reports_full_settlement&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;batch&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;SettlementBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;BatchId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"test-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CutoffUtc&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="n"&gt;Payments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Enumerable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Range&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;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&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;Payment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"p-&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="m"&gt;100m&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;settler&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;PaymentSettler&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="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AlwaysAcceptingGateway&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;Options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&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;PaymentSettlerOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ProgressReportInterval&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;logger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;NullLogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PaymentSettler&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;.&lt;/span&gt;&lt;span class="n"&gt;Instance&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;settler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SettleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;progress&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;CancellationToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;None&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;10&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;Settled&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;0&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;Failed&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;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AlwaysAcceptingGateway&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ISettlementGateway&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="n"&gt;SettlementResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;SubmitAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Payment&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;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;new&lt;/span&gt; &lt;span class="nf"&gt;SettlementResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PaymentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Accepted&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;ReasonCode&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;TestHostBuilder&lt;/code&gt;, no &lt;code&gt;local.settings.json&lt;/code&gt;, no &lt;code&gt;func start&lt;/code&gt;. The test runs in milliseconds. The hand-rolled &lt;code&gt;AlwaysAcceptingGateway&lt;/code&gt; is the lever that makes the assertion deterministic: the &lt;code&gt;FakeSettlementGateway&lt;/code&gt; shipped with the sample uses a hash-and-threshold check that is great for reproducible demos and inconvenient when the question is "what happens when this specific batch is all accepted?". Microsoft's &lt;a href="https://learn.microsoft.com/dotnet/azure/sdk/unit-testing-mocking" rel="noopener noreferrer"&gt;Azure SDK unit-testing guide&lt;/a&gt; shows both options for &lt;code&gt;Settlement.Core&lt;/code&gt;-style services: a hand-rolled subclass of the dependency (which is what &lt;code&gt;AlwaysAcceptingGateway&lt;/code&gt; is here) or a mocking library like Moq or NSubstitute. Pick the mocking library when the assertion is on interactions; pick a subclass when the assertion is on behaviour.&lt;/p&gt;

&lt;h3&gt;
  
  
  Integration tests at the same boundary
&lt;/h3&gt;

&lt;p&gt;The integration suite swaps the fake for the real implementation (a typed &lt;code&gt;HttpClient&lt;/code&gt; against the payments network) and runs the same &lt;code&gt;SettleAsync&lt;/code&gt; call. The trigger is still not in the picture; &lt;code&gt;dotnet test&lt;/code&gt; walks the workload directly. For the App Service and Container App variants, the queue surface drains through &lt;code&gt;QueueClient&lt;/code&gt; against &lt;a href="https://learn.microsoft.com/azure/storage/common/storage-use-azurite" rel="noopener noreferrer"&gt;Azurite&lt;/a&gt;. Azurite supports Blob and Queue in GA; the Table emulator is in preview, which is only worth flagging if the suite grows a Table dependency.&lt;/p&gt;

&lt;p&gt;The Functions variant has no first-party in-process test host for the isolated worker. The integration story there is "run &lt;code&gt;func start&lt;/code&gt; once per build and stimulate the queue." Microsoft documents one in-process Functions test fixture, &lt;code&gt;Microsoft.DurableTask.InProcessTestHost&lt;/code&gt;, in the &lt;a href="https://learn.microsoft.com/azure/azure-functions/durable-functions/durable-functions-unit-testing" rel="noopener noreferrer"&gt;Durable Functions unit-testing guide&lt;/a&gt;, and it only covers Durable orchestrations. For a plain queue or HTTP trigger that does no replay, the trigger gets one smoke test per build; everything else is xUnit at the service.&lt;/p&gt;

&lt;h3&gt;
  
  
  The smoke test that earns its keep
&lt;/h3&gt;

&lt;p&gt;One end-to-end test per build, run against &lt;code&gt;func start&lt;/code&gt;, confirms the binding wires up. Its job is to catch "the queue name in &lt;code&gt;host.json&lt;/code&gt; does not match what the worker reads" and similar host-configuration mistakes. It does not assert business logic; the unit suite already does that in milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after: a refactoring walkthrough
&lt;/h2&gt;

&lt;p&gt;The trigger before-and-after is above. The two remaining pieces of the diff are the service &lt;code&gt;IPaymentSettler&lt;/code&gt; resolves to and a second host that consumes the same service. Reading both alongside the Function makes the migration claim specific: there is exactly one place the workload lives, and the hosts compete to be the cheapest way to call it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PaymentSettler.SettleAsync&lt;/code&gt; is what &lt;code&gt;IPaymentSettler&lt;/code&gt; resolves to. The full method body fits on one screen, in &lt;code&gt;Settlement.Core/Services/PaymentSettler.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;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;SettlementProgress&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;SettleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;SettlementBatch&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IProgress&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SettlementProgress&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt; &lt;span class="n"&gt;progress&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;settled&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;failed&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;total&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Payments&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;processed&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;interval&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;ProgressReportInterval&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;"Settling batch {BatchId}: total={Total}, cutoff={CutoffUtc:O}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BatchId&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;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CutoffUtc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Payments&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="nf"&gt;ThrowIfCancellationRequested&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;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SubmitAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&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;Accepted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;settled&lt;/span&gt;&lt;span class="p"&gt;++;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;failed&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;"Settlement rejected for {PaymentId}: {ReasonCode}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PaymentId&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;ReasonCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;processed&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;processed&lt;/span&gt; &lt;span class="p"&gt;%&lt;/span&gt; &lt;span class="n"&gt;interval&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;processed&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="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&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="nf"&gt;SettlementProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failed&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="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;final&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;SettlementProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failed&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;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;"Batch {BatchId} settled: settled={Settled}, failed={Failed}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BatchId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Settled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Failed&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;final&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;Constructor dependencies: &lt;code&gt;ISettlementGateway&lt;/code&gt;, &lt;code&gt;IOptions&amp;lt;PaymentSettlerOptions&amp;gt;&lt;/code&gt;, &lt;code&gt;ILogger&amp;lt;PaymentSettler&amp;gt;&lt;/code&gt;. No &lt;code&gt;Microsoft.Azure.Functions.*&lt;/code&gt;. No &lt;code&gt;Azure.Storage.*&lt;/code&gt;. The host's identity (queue trigger vs &lt;code&gt;BackgroundService&lt;/code&gt; vs anything else) is below the abstraction.&lt;/p&gt;

&lt;p&gt;The App Service host is one of the hosts below it. &lt;code&gt;Settlement.AppService/Services/SettlementWorker.cs&lt;/code&gt; extends &lt;code&gt;BackgroundService&lt;/code&gt;, drains the same queue the Function reads, and dispatches each message through &lt;code&gt;ProcessAsync&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;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="nf"&gt;ProcessAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QueueMessage&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;SettlementBatch&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;batch&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="n"&gt;batch&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;SettlementBatch&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="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Discarding malformed message {MessageId}"&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;MessageId&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;queueClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DeleteMessageAsync&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;MessageId&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;PopReceipt&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="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;batch&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;await&lt;/span&gt; &lt;span class="n"&gt;queueClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DeleteMessageAsync&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;MessageId&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;PopReceipt&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="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;progress&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;Progress&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SettlementProgress&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BatchId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;settler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SettleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;progress&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;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BatchId&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;queueClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DeleteMessageAsync&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;MessageId&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;PopReceipt&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="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OperationCanceledException&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="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"Settlement of batch {BatchId} failed; message will reappear after visibility timeout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BatchId&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 dispatch line that matters is &lt;code&gt;await settler.SettleAsync(batch, progress, cancellationToken);&lt;/code&gt;. Same &lt;code&gt;IPaymentSettler&lt;/code&gt;, same three-argument signature, same cancellation propagation as the Function. The App Service variant passes a real &lt;code&gt;IProgress&amp;lt;SettlementProgress&amp;gt;&lt;/code&gt; (the worker keeps a live &lt;code&gt;/status&lt;/code&gt; endpoint backed by &lt;code&gt;SettlementWorkerStatus&lt;/code&gt;); the Function and Container App variants pass &lt;code&gt;null&lt;/code&gt;. That is the entire workload diff between the three hosts.&lt;/p&gt;

&lt;p&gt;What does differ between hosts is composition root and activation surface. The Function App's &lt;code&gt;Program.cs&lt;/code&gt; is shorter because the binding owns the queue client; the App Service and Container App hosts register &lt;code&gt;QueueServiceClient&lt;/code&gt; via &lt;code&gt;AddAzureClients&lt;/code&gt; and derive &lt;code&gt;QueueClient&lt;/code&gt; from it. The activation surface is &lt;code&gt;[QueueTrigger]&lt;/code&gt; for the Function, &lt;code&gt;BackgroundService.ExecuteAsync&lt;/code&gt; for the other two. Everything below those lines, &lt;code&gt;AddSettlementCore()&lt;/code&gt;, the gateway registration, the options binding, the logger pipeline, is shared verbatim. The host moves; the workload does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Series wrap-up: where to go from here
&lt;/h2&gt;

&lt;p&gt;Series 2 set out to answer one question: when does Azure Functions stop paying its freight, and what do you do about it? The six articles trace the ladder.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/martin_oehlert/running-azure-functions-in-docker-why-and-how-1hal"&gt;Running Azure Functions in Docker&lt;/a&gt; and &lt;a href="https://dev.to/martin_oehlert/docker-pitfalls-i-hit-and-how-to-avoid-them-2395"&gt;Docker Pitfalls I Hit&lt;/a&gt; packaged the Function App into a container so the runtime moved with the code. &lt;a href="https://dev.to/martin_oehlert/scaling-azure-functions-consumption-vs-premium-vs-dedicated-2gm"&gt;Scaling Azure Functions&lt;/a&gt; made the cost ceilings visible by walking the Consumption, Premium, and Dedicated plans against real workloads. &lt;a href="https://dev.to/martin_oehlert/structuring-complex-function-apps-project-organization-5977"&gt;Structuring Complex Function Apps&lt;/a&gt; reorganised the project structure so refactoring stayed tractable as the function count grew. &lt;a href="https://dev.to/martin_oehlert/when-azure-functions-fight-back-signs-youve-outgrown-them-1o5c"&gt;When Azure Functions Fight Back&lt;/a&gt; enumerated the four signals that justify a move: timeout walls, sprawl, coupling patterns, cost crossover. This article closes the loop by making the move mechanical. Once the trigger is a thin controller and the workload sits behind &lt;code&gt;IPaymentSettler&lt;/code&gt;, the diff between Function App, App Service, and Container App is a &lt;code&gt;Program.cs&lt;/code&gt; change.&lt;/p&gt;

&lt;p&gt;Three places to go from here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Series 3 (forthcoming)&lt;/strong&gt; integrates .NET Aspire into the Functions workflow: AppHost orchestration, Service Bus, Storage, and Redis as Aspire resources, and &lt;code&gt;azd&lt;/code&gt; deployment to Container Apps. The decoupled &lt;code&gt;Settlement.Core&lt;/code&gt; library carries straight across; only the composition root learns about Aspire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/MigrationDemo" rel="noopener noreferrer"&gt;&lt;code&gt;MigrationDemo&lt;/code&gt;&lt;/a&gt; sample&lt;/strong&gt; is the working reference: one workload, three hosts, identical behaviour. Clone it, swap the connection string for a service URI, and the production-credential path lights up against managed identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Learn migration guidance&lt;/strong&gt; covers the surface this article does not: &lt;a href="https://learn.microsoft.com/azure/azure-functions/migrate-dotnet-to-isolated-model" rel="noopener noreferrer"&gt;moving from in-process to isolated worker&lt;/a&gt; (still relevant as the decoupling that makes any further migration tractable) and the broader &lt;a href="https://learn.microsoft.com/azure/migrate/concepts-azure-webapps-assessment-calculation" rel="noopener noreferrer"&gt;App Service migration overview&lt;/a&gt; when the destination is not Functions at all.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The decoupling work earns its keep the first time a queue handler runs past 10 minutes and the answer is "register a &lt;code&gt;BackgroundService&lt;/code&gt; against the same queue" instead of "rewrite the workload".&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing question
&lt;/h2&gt;

&lt;p&gt;Which Azure SDK type was hardest for you to push behind an interface: &lt;code&gt;BlobClient&lt;/code&gt;, &lt;code&gt;ServiceBusSender&lt;/code&gt;, or something else? Reply with the type name.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Azure Functions Beyond the Basics&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;Continues from &lt;a href="https://dev.to/martin_oehlert/series/38960"&gt;Azure Functions for .NET Developers&lt;/a&gt; (Parts 1-9)&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: &lt;a href="https://dev.to/martin_oehlert/running-azure-functions-in-docker-why-and-how-1hal"&gt;Running Azure Functions in Docker: Why and How&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 2: &lt;a href="https://dev.to/martin_oehlert/docker-pitfalls-i-hit-and-how-to-avoid-them-2395"&gt;Docker Pitfalls I Hit (And How to Avoid Them)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 3: &lt;a href="https://dev.to/martin_oehlert/scaling-azure-functions-consumption-vs-premium-vs-dedicated-2gm"&gt;Scaling Azure Functions: Consumption vs Premium vs Dedicated&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 4: &lt;a href="https://dev.to/martin_oehlert/structuring-complex-function-apps-project-organization-5977"&gt;Structuring Complex Function Apps: Project Organization&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 5: &lt;a href="https://dev.to/martin_oehlert/when-azure-functions-fight-back-signs-youve-outgrown-them-1o5c"&gt;When Azure Functions Fight Back: Signs You've Outgrown Them&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 6: Preparing for Migration: Decoupling Your Function Logic (this article)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>dotnet</category>
      <category>testing</category>
    </item>
    <item>
      <title>When Azure Functions Fight Back: Signs You've Outgrown Them</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 15 May 2026 09:41:53 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/when-azure-functions-fight-back-signs-youve-outgrown-them-1o5c</link>
      <guid>https://dev.to/martin_oehlert/when-azure-functions-fight-back-signs-youve-outgrown-them-1o5c</guid>
      <description>&lt;p&gt;Your queue handler hit the 10-minute Consumption ceiling last week. You restructured it to checkpoint, and the next month-end batch still creeps over. The question now is not how to wring one more workaround out of Functions. It is when the next workaround stops being cheaper than moving the job onto a different host. Four signals push the answer past "still cheaper": performance walls, complexity sprawl, coupling patterns the platform makes worse, and a cost crossover that arrives sooner than most teams plan for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance walls you will hit
&lt;/h2&gt;

&lt;p&gt;Four limits decide how far Functions can carry the workload: how long any one invocation can run, how much memory it can use, how many sockets it can hold open, and what its file system actually persists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Execution timeout per plan
&lt;/h3&gt;

&lt;p&gt;The numbers from the &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-scale#function-app-timeout-duration" rel="noopener noreferrer"&gt;hosting plan timeout reference&lt;/a&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.amazonaws.com%2Fuploads%2Farticles%2Fm95oz9cjygoa31tc39p4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm95oz9cjygoa31tc39p4.png" alt="Execution timeout per plan" width="606" height="256"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Cross-cutting cap: HTTP triggers that do not respond within &lt;strong&gt;230 seconds&lt;/strong&gt; are cut off by the Azure Load Balancer with HTTP 502 regardless of &lt;code&gt;functionTimeout&lt;/code&gt;. The function keeps running but cannot return a response. Sources: &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-bindings-http-webhook-trigger#limits" rel="noopener noreferrer"&gt;HTTP trigger limits&lt;/a&gt;, &lt;a href="https://learn.microsoft.com/troubleshoot/azure/app-service/web-request-times-out-app-service" rel="noopener noreferrer"&gt;Web request times out in App Service&lt;/a&gt;. For longer work, Microsoft points at the &lt;a href="https://learn.microsoft.com/azure/azure-functions/durable-functions/durable-functions-http-features#async-operation-tracking" rel="noopener noreferrer"&gt;Durable Functions async HTTP pattern&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-host-json#functiontimeout" rel="noopener noreferrer"&gt;host.json: &lt;code&gt;functionTimeout&lt;/code&gt;&lt;/a&gt; describes what happens at the cap: "When an execution exceeds this duration, a timeout error occurs and the language worker process restarts." The worker is killed, and in-flight invocations on it are lost. What the trigger does next is per binding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Service Bus&lt;/strong&gt;: PeekLock with &lt;code&gt;autoComplete = true&lt;/code&gt;. On host crash the lock expires, and on next visibility the message reappears with &lt;code&gt;DeliveryCount&lt;/code&gt; incremented. After &lt;code&gt;MaxDeliveryCount&lt;/code&gt; (default 10) it lands in &lt;code&gt;&amp;lt;queue&amp;gt;/$deadletterqueue&lt;/code&gt; (&lt;a href="https://learn.microsoft.com/azure/service-bus-messaging/service-bus-dead-letter-queues#maximum-delivery-count" rel="noopener noreferrer"&gt;Service Bus dead-letter queues&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage Queue&lt;/strong&gt;: visibility timeout per message. On host crash the storage-default 10-minute timeout takes over. After 5 failed attempts the message moves to &lt;code&gt;&amp;lt;queue&amp;gt;-poison&lt;/code&gt; (&lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-bindings-storage-queue-trigger#peek-lock" rel="noopener noreferrer"&gt;Storage queue trigger: Peek lock&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blob trigger&lt;/strong&gt;: same five-attempt default, with failed blobs landing in &lt;code&gt;webjobs-blobtrigger-poison&lt;/code&gt; (&lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-bindings-storage-blob-trigger#poison-blobs" rel="noopener noreferrer"&gt;Poison blobs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP&lt;/strong&gt;: caller already saw the 502 at 230 s. No automatic retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timer&lt;/strong&gt;: per &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-bindings-timer#usage" rel="noopener noreferrer"&gt;Timer trigger: Retry behavior&lt;/a&gt;, "the timer trigger doesn't retry after a function fails."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A subtle one to flag: only &lt;strong&gt;Cosmos DB, Event Hubs, Kafka, and Timer&lt;/strong&gt; support host-level retry policies (&lt;code&gt;[FixedDelayRetry]&lt;/code&gt;, &lt;code&gt;[ExponentialBackoffRetry]&lt;/code&gt;). For Service Bus and Storage Queue, the binding's native retry semantics are the only mechanism (&lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-bindings-error-pages#retry-policies" rel="noopener noreferrer"&gt;Azure Functions error handling and retries&lt;/a&gt;). Decorating a Service Bus trigger with &lt;code&gt;[ExponentialBackoffRetry]&lt;/code&gt; does nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix that lets you stay (when it can)
&lt;/h3&gt;

&lt;p&gt;Most "we hit the timeout" stories are workloads that can be split into chunks with a checkpoint between them. A queue-triggered batch that processes 50,000 items in 50 ms each runs 42 minutes. The same handler that processes 500 at a time and writes a cursor blob between chunks survives any number of restarts. From the &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/BatchCheckpointDemo" rel="noopener noreferrer"&gt;companion sample&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="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;DrainBatchFunction&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;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;QueueTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"batches"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"AzureWebJobsStorage"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;BatchCommand&lt;/span&gt; &lt;span class="n"&gt;command&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;await&lt;/span&gt; &lt;span class="n"&gt;cursors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateIfNotExistsAsync&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;checkpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cursors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBlobClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"batch-&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BatchId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.cursor"&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;lastCommitted&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;ReadCursorAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checkpoint&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;await&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ChunksAfterAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                       &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BatchId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lastCommitted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_chunkSize&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ProcessAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&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;// Commit the cursor *before* the next chunk; if the worker is killed&lt;/span&gt;
        &lt;span class="c1"&gt;// immediately after this upload, the next attempt resumes here.&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;checkpoint&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="n"&gt;BinaryData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastItemId&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="n"&gt;CultureInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvariantCulture&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
            &lt;span class="n"&gt;overwrite&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;cancellationToken&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 shape is what matters: chunk, process, commit cursor, repeat. Each chunk must be small enough that the worst-case batch (500 items at 50 ms = 25 s) finishes well inside the timeout. The cursor write is the moment durability shifts. On retry, &lt;code&gt;ReadCursorAsync&lt;/code&gt; resumes at the last committed item instead of restarting at item 1.&lt;/p&gt;

&lt;p&gt;This pattern keeps you on Functions. When the &lt;em&gt;single chunk&lt;/em&gt; itself runs longer than the timeout (a 12-minute database query, a multi-gigabyte file copy, a 30-minute model inference), the workload has outgrown the platform. No chunk size helps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory ceilings
&lt;/h3&gt;

&lt;p&gt;Per-instance memory from the &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-scale#service-limits" rel="noopener noreferrer"&gt;service limits table&lt;/a&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.amazonaws.com%2Fuploads%2Farticles%2Fu3e7n9rvsmae5i2mpakv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu3e7n9rvsmae5i2mpakv.png" alt="Memory per instance per plan" width="568" height="214"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two surprises in that list. Flex Consumption has a &lt;strong&gt;512 MB&lt;/strong&gt; SKU. Most teams reading the marketing page assume Flex starts where Premium does. And from the trigger's perspective, &lt;strong&gt;OOM and timeout look the same&lt;/strong&gt;: the OS terminates the worker, the host restarts, and the per-binding retry semantics from the previous section decide whether the input is replayed or dropped. The closest official reference is the &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-host-json#healthmonitor" rel="noopener noreferrer"&gt;host health monitor&lt;/a&gt;, which can recycle the host preemptively when performance counters stay above 80% within the health-check window.&lt;/p&gt;

&lt;p&gt;The blob trigger has a documented memory amplifier worth knowing. Microsoft's &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-bindings-storage-blob-trigger#memory-usage-and-concurrency" rel="noopener noreferrer"&gt;Blob trigger: Memory usage and concurrency&lt;/a&gt; warns that "the runtime must load the entire blob into memory more than one time during processing" if you bind to a non-streaming type, and concurrency multiplies the effect. Bind to &lt;code&gt;Stream&lt;/code&gt; for anything past a few MB.&lt;/p&gt;

&lt;h3&gt;
  
  
  SNAT ports and connection exhaustion
&lt;/h3&gt;

&lt;p&gt;Two distinct outbound limits, easy to confuse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SNAT port budget per instance.&lt;/strong&gt; From &lt;a href="https://learn.microsoft.com/azure/app-service/troubleshoot-intermittent-outbound-connection-errors#cause" rel="noopener noreferrer"&gt;Troubleshoot intermittent outbound connection errors&lt;/a&gt;: "Each instance on Azure App service is initially given a preallocated number of &lt;em&gt;128&lt;/em&gt; SNAT ports." Ports apply to the same destination tuple (address + port) and are reclaimed by the load balancer &lt;strong&gt;four minutes&lt;/strong&gt; after the connection closes. Microsoft's recommendation is to keep usage under &lt;strong&gt;100 outbound connections per unique remote endpoint per instance&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total outbound TCP connections per instance.&lt;/strong&gt; Consumption is capped at &lt;strong&gt;600 active (1,200 total) per instance&lt;/strong&gt;, and the runtime logs &lt;code&gt;Host thresholds exceeded: Connections&lt;/code&gt; at the limit. Flex, Premium, and Dedicated are listed as "unbounded" (Dedicated still subject to App Service worker-size caps).&lt;/p&gt;

&lt;p&gt;The fastest way to walk into both limits is the canonical Functions anti-pattern: &lt;code&gt;new HttpClient()&lt;/code&gt; inside a function body. Each invocation creates a new socket pool, and sockets sit in &lt;code&gt;TIME_WAIT&lt;/code&gt; after disposal, compounded by the four-minute SNAT reclaim. At any reasonable RPS, the 128-port budget for a destination host is exhausted, and the function sees intermittent connect failures or &lt;code&gt;SocketException&lt;/code&gt;. The &lt;a href="https://learn.microsoft.com/azure/azure-functions/errors-diagnostics/sdk-rules/azf0002" rel="noopener noreferrer"&gt;AZF0002 analyzer&lt;/a&gt; flags the call site at build time.&lt;/p&gt;

&lt;p&gt;The recommended fix is &lt;code&gt;IHttpClientFactory&lt;/code&gt;, registered once in &lt;code&gt;Program.cs&lt;/code&gt;. From the &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/HttpClientFactoryDemo" rel="noopener noreferrer"&gt;companion sample&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="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="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PaymentsOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PaymentsOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SectionName&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidateDataAnnotations&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidateOnStart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddHttpClient&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IPaymentsApi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PaymentsApi&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;,&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;=&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;options&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;IOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PaymentsOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;().&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseAddress&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="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseAddress&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="n"&gt;Timeout&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="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TimeoutSeconds&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;AddStandardResilienceHandler&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The factory caches &lt;code&gt;HttpMessageHandler&lt;/code&gt; instances (default lifetime 2 min per &lt;a href="https://learn.microsoft.com/dotnet/core/extensions/httpclient-factory#httpclient-lifetime-management" rel="noopener noreferrer"&gt;HttpClient lifetime management&lt;/a&gt;) and rotates them so DNS changes are picked up. The &lt;code&gt;AddStandardResilienceHandler&lt;/code&gt; call layers in retry, circuit breaker, and timeout policies from &lt;code&gt;Microsoft.Extensions.Http.Resilience&lt;/code&gt; without an extra DI dance. The function takes &lt;code&gt;IPaymentsApi&lt;/code&gt; on its primary constructor and never calls &lt;code&gt;new HttpClient()&lt;/code&gt; again.&lt;/p&gt;

&lt;p&gt;One caveat from &lt;a href="https://learn.microsoft.com/dotnet/fundamentals/networking/http/httpclient-guidelines#recommended-use" rel="noopener noreferrer"&gt;HttpClient guidelines: Recommended use&lt;/a&gt;: the factory shares a &lt;code&gt;CookieContainer&lt;/code&gt; across pooled handlers. If your client depends on cookies, prefer a singleton with an explicit &lt;code&gt;SocketsHttpHandler&lt;/code&gt; that sets &lt;code&gt;PooledConnectionLifetime&lt;/code&gt; instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Database connections do not get the same fix
&lt;/h3&gt;

&lt;p&gt;ADO.NET pools per process, and Functions runs one process per instance. Microsoft's &lt;a href="https://learn.microsoft.com/azure/azure-functions/manage-connections#sqlclient-connections" rel="noopener noreferrer"&gt;SqlClient guidance&lt;/a&gt; is direct: "ADO.NET implements connection pooling by default. But because you can still run out of connections, you should optimize connections to the database."&lt;/p&gt;

&lt;p&gt;The math is the trap. Each Consumption instance carries its own pool (default &lt;code&gt;Max Pool Size = 100&lt;/code&gt;), and the platform can spin up to &lt;strong&gt;200&lt;/strong&gt; Consumption instances (&lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-scale#service-limits" rel="noopener noreferrer"&gt;service limits&lt;/a&gt;). At full scale-out: &lt;code&gt;200 instances * 100 connections = 20,000 potential connections&lt;/code&gt; against the database. Premium is capped at 100 instances, Flex at 1,000. Most managed databases fall over well below that.&lt;/p&gt;

&lt;p&gt;The fix lives at the database, not in Functions. Lower the pool size per instance, cap the application's max scale-out, or front the database with a connection pooler (PgBouncer, RDS Proxy equivalents on Azure Database for PostgreSQL Flexible Server).&lt;/p&gt;

&lt;h3&gt;
  
  
  Local file system
&lt;/h3&gt;

&lt;p&gt;Three behaviours worth distinguishing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-instance ephemeral temp.&lt;/strong&gt; From &lt;a href="https://learn.microsoft.com/azure/app-service/operating-system-functionality#file-access" rel="noopener noreferrer"&gt;Operating system functionality in App Service&lt;/a&gt;: &lt;code&gt;%SystemDrive%\local&lt;/code&gt; is reserved for temporary local storage, "not persistent across app restarts." Plan-by-plan capacity ranges from 0.5 GB (Consumption) to 21-140 GB (Premium / Dedicated). Scale-out does not just reset temp. It makes the data invisible: instance A writes &lt;code&gt;/tmp/cache.json&lt;/code&gt;, and instance B never sees it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persisted shares.&lt;/strong&gt; Premium and Dedicated can mount Azure Files (SMB or NFS). &lt;strong&gt;Flex Consumption supports SMB and read-only Blobs but not NFS&lt;/strong&gt; (&lt;a href="https://learn.microsoft.com/azure/azure-functions/concept-file-access-options" rel="noopener noreferrer"&gt;Choose a file access strategy&lt;/a&gt;). SMB cold-start latency is documented at 200-500 ms on first execution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read-only file systems.&lt;/strong&gt; When &lt;code&gt;WEBSITE_RUN_FROM_PACKAGE&lt;/code&gt; is set, "the &lt;code&gt;wwwroot&lt;/code&gt; folder is read-only and you receive an error if you write files to this directory" (&lt;a href="https://learn.microsoft.com/azure/azure-functions/run-functions-from-deployment-package#general-considerations" rel="noopener noreferrer"&gt;Run from package: General considerations&lt;/a&gt;). The same page is explicit: "Don't add the &lt;code&gt;WEBSITE_RUN_FROM_PACKAGE&lt;/code&gt; app setting to apps on the Flex Consumption plan." Flex's deployment model treats the package as read-only by design. Container Apps follows container-image semantics: image layers are read-only.&lt;/p&gt;

&lt;p&gt;The Durable Functions provider doc gives the blunt warning: "Storing payloads to local disks is &lt;em&gt;not&lt;/em&gt; recommended, since on-disk state isn't guaranteed to be available" (&lt;a href="https://learn.microsoft.com/azure/azure-functions/durable-functions/durable-functions-azure-storage-provider#azure-storage-representation-in-a-task-hub" rel="noopener noreferrer"&gt;Azure Storage representation in a task hub&lt;/a&gt;). Anything you want to read on a different instance, or after a restart, belongs in Blob, Cosmos, or another external store.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complexity signals that indicate a problem
&lt;/h2&gt;

&lt;p&gt;Performance walls fail the workload outright. Complexity signals fail it slowly: the function count keeps climbing, the orchestration diagram keeps growing legends, and at some point you cannot describe the system without a whiteboard. None of the limits below are timeouts. They are the shape of the architecture telling you it has outgrown the deployment unit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Durable Functions sub-orchestration sprawl
&lt;/h3&gt;

&lt;p&gt;Microsoft lists three legitimate uses for &lt;code&gt;CallSubOrchestratorAsync&lt;/code&gt; (&lt;a href="https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-sub-orchestrations#when-to-use-sub-orchestrations" rel="noopener noreferrer"&gt;Sub-orchestrations: When to use&lt;/a&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Compose reusable workflow building blocks shared across parents.&lt;/li&gt;
&lt;li&gt;Fan out parallel instances of the same orchestrator and wait for all.&lt;/li&gt;
&lt;li&gt;Organise a large orchestration into named, testable pieces.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a sub-orchestration is not doing one of those three jobs, it is decoration, and the cost compounds. The same docs add a hard constraint: "Sub-orchestrations must be defined in the same app as the parent orchestration." The cross-app workaround is the &lt;a href="https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-http-features" rel="noopener noreferrer"&gt;HTTP 202 polling pattern&lt;/a&gt;, a different programming model with no parent/child semantics, no shared retry policy, and no automatic exception propagation. The reason is the &lt;a href="https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-task-hubs#use-multiple-apps-with-separate-task-hubs" rel="noopener noreferrer"&gt;task hub model&lt;/a&gt;: "If multiple apps use the same task hub, they compete for messages, which can result in undefined behavior, including orchestrations getting unexpectedly stuck."&lt;/p&gt;

&lt;p&gt;The decision worth surfacing in your design review: &lt;strong&gt;task hub partition count is immutable after creation&lt;/strong&gt;. Default 4, max 16. From &lt;a href="https://learn.microsoft.com/azure/azure-functions/durable-functions/durable-functions-perf-and-scale#partition-count" rel="noopener noreferrer"&gt;Performance and scale: Partition count&lt;/a&gt;: "You can't change the partition count after you create a task hub. Set it high enough to meet expected scale-out requirements." Most teams discover this when their orchestrator throughput plateaus and they reach for the dial that does not exist.&lt;/p&gt;

&lt;p&gt;Sprawl signals inside one app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Parent depth of 3+ levels where the grandchild does almost no work.&lt;/li&gt;
&lt;li&gt;Sub-orchestrators that wrap a single activity call.&lt;/li&gt;
&lt;li&gt;Parents fanning out to sub-orchestrators that themselves fan out: one instance ID can spawn dozens of children, each consuming control queue capacity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Orchestrator code constraints
&lt;/h3&gt;

&lt;p&gt;Orchestrators replay from history every time a new event arrives. The runtime requires deterministic code: the same input must produce the same call sequence on every replay. From &lt;a href="https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-code-constraints" rel="noopener noreferrer"&gt;Orchestrator function code constraints&lt;/a&gt;, the following must not appear inside an orchestrator body:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DateTime.Now&lt;/code&gt; / &lt;code&gt;DateTime.UtcNow&lt;/code&gt; (use &lt;code&gt;context.CurrentUtcDateTime&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Guid.NewGuid()&lt;/code&gt; (use &lt;code&gt;context.NewGuid()&lt;/code&gt;, which returns Type 5 UUIDs derived from instance ID).&lt;/li&gt;
&lt;li&gt;Bindings, including the orchestration client and entity client bindings. I/O lives in activities.&lt;/li&gt;
&lt;li&gt;Static variables and environment variables.&lt;/li&gt;
&lt;li&gt;Direct network or HTTP calls.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Task.Run&lt;/code&gt;, &lt;code&gt;Task.Delay&lt;/code&gt;, &lt;code&gt;Thread.Sleep&lt;/code&gt;, &lt;code&gt;HttpClient.SendAsync&lt;/code&gt;. Use &lt;code&gt;context.CreateTimer&lt;/code&gt; for delays.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The clean rule is one sentence: &lt;strong&gt;orchestrators schedule, activities act&lt;/strong&gt;. Anything that reads time, calls the network, or generates a random value belongs in an activity. The framework throws &lt;code&gt;NonDeterministicOrchestrationException&lt;/code&gt; sometimes, but the docs are explicit: "this detection behavior won't catch all violations, and you shouldn't depend on it." Violations ship and break weeks later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared external state as bottleneck
&lt;/h3&gt;

&lt;p&gt;Account-level ceilings from &lt;a href="https://learn.microsoft.com/azure/storage/common/scalability-targets-standard-account" rel="noopener noreferrer"&gt;Standard storage account scalability targets&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;20,000 RPS&lt;/strong&gt; per general-purpose v2 account in most regions, and &lt;strong&gt;40,000 RPS&lt;/strong&gt; in higher-tier regions.&lt;/li&gt;
&lt;li&gt;Hitting any of these returns &lt;strong&gt;HTTP 503 Server Busy&lt;/strong&gt; or &lt;strong&gt;HTTP 500 Operation Timeout&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Per-service ceilings (the ones that bite first):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storage Queue&lt;/strong&gt;: account 20,000 msg/s, &lt;strong&gt;single queue tops out at 2,000 msg/s&lt;/strong&gt; (&lt;a href="https://learn.microsoft.com/azure/storage/queues/scalability-targets" rel="noopener noreferrer"&gt;Queue Storage scalability&lt;/a&gt;, &lt;a href="https://learn.microsoft.com/azure/architecture/best-practices/data-partitioning-strategies#partitioning-azure-storage-queues" rel="noopener noreferrer"&gt;Data partitioning strategies&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage Table&lt;/strong&gt;: 20,000 entities/s account-wide, &lt;strong&gt;2,000 entities/s per partition&lt;/strong&gt; (&lt;a href="https://learn.microsoft.com/azure/storage/tables/scalability-targets" rel="noopener noreferrer"&gt;Table Storage scalability&lt;/a&gt;). Throttling shows as &lt;code&gt;PercentThrottlingError&lt;/code&gt;. Date-as-partition-key is the canonical anti-pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cosmos DB hot partition&lt;/strong&gt;: hard ceiling of &lt;strong&gt;10,000 RU/s per logical partition&lt;/strong&gt; (&lt;a href="https://learn.microsoft.com/azure/cosmos-db/partitioning#physical-partitions" rel="noopener noreferrer"&gt;Partitioning and horizontal scaling&lt;/a&gt;), regardless of total provisioned throughput. Splitting does not help when the key is genuinely hot (&lt;a href="https://learn.microsoft.com/azure/cosmos-db/how-to-redistribute-throughput-across-partitions" rel="noopener noreferrer"&gt;Redistribute throughput&lt;/a&gt;). The fix is a different partition key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Durable Functions sits on top of those numbers. Every task hub creates two Azure Tables (&lt;code&gt;&amp;lt;hub&amp;gt;History&lt;/code&gt;, &lt;code&gt;&amp;lt;hub&amp;gt;Instances&lt;/code&gt;), one work-item queue, one control queue per partition, and blob containers for leases and large messages (&lt;a href="https://learn.microsoft.com/azure/azure-functions/durable-functions/durable-functions-azure-storage-provider#azure-storage-representation-in-a-task-hub" rel="noopener noreferrer"&gt;Azure Storage representation in a task hub&lt;/a&gt;). When several apps point at the same storage account, every one fights for the same per-partition 2,000 entities/s on the History and Instances tables. Microsoft's reliability guidance is direct: "use a separate storage account for each function app. This aspect is especially true with Durable Functions and Event Hubs triggered functions" (&lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-best-practices#configure-storage-correctly" rel="noopener noreferrer"&gt;Best practices for reliable Azure Functions&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Service Bus session locks
&lt;/h3&gt;

&lt;p&gt;The architectural smell is two or more functions in the same function app triggering on the same session-enabled queue. Sessions hold an exclusive lock per session ID (&lt;a href="https://learn.microsoft.com/azure/service-bus-messaging/message-sessions" rel="noopener noreferrer"&gt;Message sessions&lt;/a&gt;): only one receiver at a time, per-session FIFO, default &lt;code&gt;MaxDeliveryCount = 10&lt;/code&gt;. The Functions session host defaults are aggressive (&lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-bindings-service-bus#hostjson-settings" rel="noopener noreferrer"&gt;Service Bus host.json settings&lt;/a&gt;): &lt;code&gt;maxConcurrentSessions = 2000&lt;/code&gt;, &lt;code&gt;maxConcurrentCalls = 16&lt;/code&gt; per session. Thread starvation at high &lt;code&gt;MaxConcurrentSessions&lt;/code&gt; is documented in the &lt;a href="https://learn.microsoft.com/azure/service-bus-messaging/service-bus-troubleshooting-guide#troubleshoot-processor-issues" rel="noopener noreferrer"&gt;troubleshooting guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two functions in the same app are not parallelising work over those sessions. They are competing for session locks. &lt;code&gt;SessionLockLost&lt;/code&gt; shows up when the lock expires before renewal, the partition rebalances, or the AMQP link is idle for 10 minutes (&lt;a href="https://learn.microsoft.com/azure/service-bus-messaging/service-bus-messaging-exceptions-latest#reason-sessionlocklost" rel="noopener noreferrer"&gt;Service Bus messaging exceptions: SessionLockLost&lt;/a&gt;). The fix is one consumer per session-enabled queue per app, period.&lt;/p&gt;

&lt;h3&gt;
  
  
  Function count vs feature count
&lt;/h3&gt;

&lt;p&gt;Microsoft does not publish a number for "too many functions in one app", but the &lt;a href="https://learn.microsoft.com/azure/azure-functions/performance-reliability#function-organization-best-practices" rel="noopener noreferrer"&gt;Function organization best practices&lt;/a&gt; describe the failure mode plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Each function that you create has a memory footprint. While this footprint is usually small, having too many functions within a function app can lead to slower startup of your app on new instances.&lt;/p&gt;

&lt;p&gt;Connection strings and other credentials stored in application settings gives all of the functions in the function app the same set of permissions in the associated resource. Consider minimizing the number of functions with access to specific credentials by moving functions that don't use those credentials to a separate function app.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A self-test you can run in 10 minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Can you name the feature each function delivers without opening the code?&lt;/li&gt;
&lt;li&gt;Does explaining one feature require drawing a diagram of 5+ function boundaries?&lt;/li&gt;
&lt;li&gt;Is your function-to-feature ratio above 3:1? (50 functions for 8 features is 6:1.)&lt;/li&gt;
&lt;li&gt;Do all functions share the same connection strings whether they use them or not?&lt;/li&gt;
&lt;li&gt;Do load profiles inside the app diverge? (Chatty queue trigger next to memory-heavy report function.)&lt;/li&gt;
&lt;li&gt;Does shipping one function redeploy 49 others?&lt;/li&gt;
&lt;li&gt;Does cold start time grow with every release?&lt;/li&gt;
&lt;li&gt;Does the same Durable task hub serve more than one feature area?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three or more is the smell. Six or more is "this should have been split a quarter ago." The W19 split-apps approach handles the first wave of this. If splitting still leaves you fighting the platform, the article you are reading is the second wave.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coupling patterns that fight the serverless model
&lt;/h2&gt;

&lt;p&gt;Some of the worst Functions deployments do not look bad on any one screen. The handlers are clean, every function is short, the metrics are healthy. The damage is in the topology: the way the functions wire to each other multiplies cost, slows change, or quietly burns money in a loop. Four shapes show up over and over.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sequential chains (the service with three methods)
&lt;/h3&gt;

&lt;p&gt;Function A writes a message. Function B is triggered by it. Function C is triggered by B's message. Every input traverses the same three hops, with no branching and no fan-out. It is a workflow, not a serverless decomposition.&lt;/p&gt;

&lt;p&gt;Microsoft's &lt;a href="https://learn.microsoft.com/azure/architecture/patterns/pipes-and-filters" rel="noopener noreferrer"&gt;Pipes and Filters pattern&lt;/a&gt; is explicit on when &lt;strong&gt;not&lt;/strong&gt; to use it: "the processing steps performed by an application aren't independent, or they have to be performed together as part of a single transaction." A three-step lockstep chain meets that condition. The same page points at &lt;a href="https://learn.microsoft.com/azure/architecture/patterns/compute-resource-consolidation" rel="noopener noreferrer"&gt;Compute Resource Consolidation&lt;/a&gt; for the consolidation: "You can group filters that should scale together in the same process."&lt;/p&gt;

&lt;p&gt;Cost per hop is five charges:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Source-function invocation (per-execution + GB-second).&lt;/li&gt;
&lt;li&gt;Queue write (one transaction).&lt;/li&gt;
&lt;li&gt;Queue read by the next function (another transaction).&lt;/li&gt;
&lt;li&gt;Serialize + deserialize (CPU on both sides).&lt;/li&gt;
&lt;li&gt;New consumer invocation (per-execution + GB-second again).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A three-function chain triples that. The corrected shapes are documented: collapse to one Functions invocation that calls the three operations as private methods (single trigger, single execution, no inter-function queues), or move to &lt;a href="https://learn.microsoft.com/azure/durable-task/common/durable-task-sequence" rel="noopener noreferrer"&gt;Durable Functions function chaining&lt;/a&gt; if the steps are genuinely separate but coordinated. The orchestrator keeps durable state and tracks the choreography explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared database schemas across Function Apps
&lt;/h3&gt;

&lt;p&gt;Microsoft's &lt;a href="https://learn.microsoft.com/azure/architecture/microservices/design/data-considerations" rel="noopener noreferrer"&gt;Data considerations for microservices&lt;/a&gt; is unambiguous:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two services shouldn't share a data store. Each service manages its own private data store, and other services can't access it directly.&lt;/p&gt;

&lt;p&gt;Services can safely share the same physical database server. Problems occur when services share the same schema, or they read and write to the same set of database tables.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two Function Apps on the same Azure SQL server with separate schemas is fine. Two Function Apps writing the same &lt;code&gt;Orders&lt;/code&gt; table is the antipattern.&lt;/p&gt;

&lt;p&gt;The cost surfaces at migration time. Additive changes (new column, new table) work. Destructive changes (rename, drop, type change, NOT NULL on a populated column) require all N apps to agree on a release order: forward-compat code shipped in advance to every app, or a coordinated cutover that removes the ability of any one app to deploy independently. Two-phase migrations (expand-and-contract) become the default. The blue/green compatibility window is the &lt;strong&gt;intersection&lt;/strong&gt; of every app's deploy windows. Each schema version must be readable and writable by every prior version of every consumer that might still be running (&lt;a href="https://learn.microsoft.com/azure/architecture/guide/multitenant/approaches/storage-data#antipatterns-to-avoid" rel="noopener noreferrer"&gt;multitenant antipatterns&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;If three apps share an &lt;code&gt;Orders&lt;/code&gt; table and one ships weekly, one biweekly, one monthly, the slowest cadence sets the floor. A hosted service that owns the schema collapses N consumers to 1, and migrations stop being a coordination problem. The trade is what you give up: per-function scaling and trigger-binding ergonomics. The &lt;a href="https://learn.microsoft.com/azure/architecture/patterns/saga#context-and-problem" rel="noopener noreferrer"&gt;Saga pattern: Context and problem&lt;/a&gt; acknowledges the trade explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Circular queue dependencies and poison loops
&lt;/h3&gt;

&lt;p&gt;Two defaults to cite side by side, because readers conflate them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storage Queue &lt;code&gt;maxDequeueCount&lt;/code&gt; = 5&lt;/strong&gt;. After 5 failures the message goes to &lt;code&gt;&amp;lt;originalqueue&amp;gt;-poison&lt;/code&gt; (&lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-bindings-storage-queue#hostjson-settings" rel="noopener noreferrer"&gt;Storage queue host.json settings&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service Bus &lt;code&gt;MaxDeliveryCount&lt;/code&gt; = 10&lt;/strong&gt;. After 10 attempts the message goes to the DLQ at &lt;code&gt;&amp;lt;queue path&amp;gt;/$deadletterqueue&lt;/code&gt; (&lt;a href="https://learn.microsoft.com/azure/service-bus-messaging/service-bus-dead-letter-queues#maximum-delivery-count" rel="noopener noreferrer"&gt;Service Bus dead-letter queues&lt;/a&gt;). "There's no automatic cleanup of the DLQ. Messages remain in the DLQ until you explicitly retrieve them."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Functions handles the settlement automatically: "By default, the runtime calls &lt;code&gt;Complete&lt;/code&gt; on the message if the function finishes successfully, or calls &lt;code&gt;Abandon&lt;/code&gt; if the function fails" (&lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-bindings-service-bus-trigger#peeklock-behavior" rel="noopener noreferrer"&gt;Service Bus trigger: PeekLock&lt;/a&gt;). The two antipatterns that subvert this safety net:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Retry queue that re-enqueues to the input queue.&lt;/strong&gt; Handler catches the exception, writes the message &lt;em&gt;back&lt;/em&gt; to the input queue with a transient-failure tag, returns success. The runtime never sees a failure, &lt;code&gt;dequeueCount&lt;/code&gt; resets each round trip, and the message lives forever. The &lt;code&gt;MessageId&lt;/code&gt; changes because the application is publishing a new message each time, so log correlation by ID misses it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead-letter handler that re-triggers the original function.&lt;/strong&gt; A second function with a trigger on the poison/DLQ subqueue picks up failed messages and calls back into the original function's logic (or worse, writes back to the original input queue). Result: input -&amp;gt; processing -&amp;gt; poison -&amp;gt; handler -&amp;gt; input ad infinitum. Service Bus eventually surfaces &lt;code&gt;QuotaExceeded&lt;/code&gt; (&lt;a href="https://learn.microsoft.com/azure/service-bus-messaging/service-bus-messaging-exceptions-latest#reason-quotaexceeded" rel="noopener noreferrer"&gt;messaging exceptions&lt;/a&gt;). Storage Queues do not fail nearly as loudly. The loop just costs money until somebody notices the bill.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Microsoft names the failure shape directly. The &lt;a href="https://learn.microsoft.com/azure/architecture/patterns/choreography#solution" rel="noopener noreferrer"&gt;Choreography pattern&lt;/a&gt; carries the warning: "There's a risk of cyclic dependency between saga participants because they have to consume each other's commands."&lt;/p&gt;

&lt;h3&gt;
  
  
  Detecting poison loops in Application Insights
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://learn.microsoft.com/azure/azure-monitor/app/failures-performance-transactions#transaction-diagnostics-experience" rel="noopener noreferrer"&gt;End-to-end transaction details&lt;/a&gt; view shows a Gantt chart of every server-side telemetry event for a correlated &lt;code&gt;operation_Id&lt;/code&gt; across all instrumented components. For a Functions chain, each invocation shows up as a request span with the queue-dependency calls between them, all under the same operation. &lt;strong&gt;N+1 invocations of the same function name under the same &lt;code&gt;operation_Id&lt;/code&gt; is the loop signature.&lt;/strong&gt; The &lt;a href="https://learn.microsoft.com/azure/azure-monitor/app/app-map" rel="noopener noreferrer"&gt;Application Map&lt;/a&gt; makes the cycle visible at the topology level: a node with a self-edge or a tight A-&amp;gt;B-&amp;gt;A cycle.&lt;/p&gt;

&lt;p&gt;The detection workflow is two clicks: &lt;strong&gt;Failures&lt;/strong&gt; view, drill into a sample exception, the End-to-end transaction view opens with the trace tree expanded. If you cannot tell at a glance whether a transaction is one logical request or three loops of the same one, instrument before you refactor.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuration drift
&lt;/h3&gt;

&lt;p&gt;Splitting one Function App into N copies the configuration N times. Each app has its own connection strings, API keys, storage keys in App Settings. Rotating a secret means N updates. Missing one leaves a stale credential in production until the next deploy.&lt;/p&gt;

&lt;p&gt;Key Vault references move the storage out of App Settings and into a vault. The syntax (&lt;a href="https://learn.microsoft.com/azure/app-service/app-service-key-vault-references#understand-source-app-settings-from-key-vault" rel="noopener noreferrer"&gt;Use Key Vault references as app settings&lt;/a&gt;) takes one of two forms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/mysecret)
@Microsoft.KeyVault(VaultName=myvault;SecretName=mysecret)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure mode the same page documents: "If a reference isn't resolved properly, the reference string is used instead." Real production failure pattern: the function tries to authenticate with the literal string &lt;code&gt;@Microsoft.KeyVault(...)&lt;/code&gt;, gets a 401, and the operator stares at App Settings that look correct in the portal. The &lt;code&gt;WEBSITE_KEYVAULT_REFERENCES&lt;/code&gt; env var holds resolution status for every reference, and the portal exposes a Key Vault Application Settings Diagnostics detector. Both are worth knowing before the first incident.&lt;/p&gt;

&lt;p&gt;The "partially adopted" antipattern is the worst version: some apps reference &lt;code&gt;@Microsoft.KeyVault(...)&lt;/code&gt;, others have raw values because the migration was incomplete. Rotating the secret in the vault updates the references and leaves the raw-value apps stale. Configuration looks correct in the portal until the next failure surface, usually a 401 from a downstream service hours after rotation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://learn.microsoft.com/azure/azure-app-configuration/overview" rel="noopener noreferrer"&gt;Azure App Configuration&lt;/a&gt; collapses N App Settings stores into one. "Spreading configuration settings across these components can lead to hard-to-troubleshoot errors during an application deployment. Use App Configuration to store all the settings for your application and secure their accesses in one place." App Configuration handles non-secret config and holds Key Vault references for secret values. Key Vault stays the secret store. The trade is one runtime dependency, N role assignments, and refresh semantics that are opt-in (without dynamic refresh, settings are read once at startup). For two-app workloads it might not pay. For ten-app workloads it almost always does (&lt;a href="https://learn.microsoft.com/azure/azure-app-configuration/howto-best-practices#building-applications-with-high-resiliency" rel="noopener noreferrer"&gt;App Configuration: high resiliency&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost crossover point
&lt;/h2&gt;

&lt;p&gt;"Functions is too expensive at scale" and "Functions is the cheap option" are both true. They describe different points on the same curve. The crossover happens earlier than most teams plan for, and the worked example below makes it concrete.&lt;/p&gt;

&lt;h3&gt;
  
  
  A worked example: 10 RPS at 200 ms / 256 MB
&lt;/h3&gt;

&lt;p&gt;Assumptions: 10 RPS sustained for 30 days, 200 ms execution, 256 MB memory. East US 2. Single subscription with the per-month free grants applied. All numbers verified against the Azure Retail Prices API on 2026-05-08.&lt;/p&gt;

&lt;p&gt;Volumes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Executions: &lt;code&gt;10 * 3,600 * 24 * 30&lt;/code&gt; = 25,920,000&lt;/li&gt;
&lt;li&gt;GB-seconds: &lt;code&gt;0.25 * 0.2 * 25,920,000&lt;/code&gt; = 1,296,000&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsuq20hcisiu9c9o6o7sn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsuq20hcisiu9c9o6o7sn.png" alt="Cost per host at 10 RPS / 200 ms / 256 MB" width="738" height="296"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Flex number is the trap. Flex bills a &lt;strong&gt;1-second minimum&lt;/strong&gt; per execution then rounds to 100 ms above that (&lt;a href="https://learn.microsoft.com/azure/azure-functions/flex-consumption-plan#billing" rel="noopener noreferrer"&gt;Flex billing&lt;/a&gt;). At 200 ms / 256 MB, every invocation bills as if it ran 1 second, which is 5x the GB-seconds and pushes the bill 9x above legacy Consumption. Teams switching from Consumption to Flex "for the per-function scaling" walk into this and call the platform expensive. Verify on real workload before committing.&lt;/p&gt;

&lt;p&gt;The crossover with EP1 is the other number worth keeping in your head. Setting &lt;code&gt;Consumption_total = EP1_floor&lt;/code&gt; and solving for RPS at 200 ms / 256 MB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cost ≈ 2.592 * R - 6.60 = 145.93
R ≈ 58.8 RPS sustained
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the 200 ms / 256 MB shape, &lt;strong&gt;Consumption ties EP1 around 60 RPS sustained&lt;/strong&gt;. Below that, Consumption wins on cost. Above that, EP1 starts winning and the Premium-only reasons (always-on, VNet integration, longer timeouts) compound the case. The crossover shifts with execution length: at 1 s / 256 MB it falls to about 12 RPS, and at 50 ms it pushes past 240 RPS. Pick the shape that matches your workload before you quote a number.&lt;/p&gt;

&lt;p&gt;AKS gets one sentence: if you need Kubernetes primitives, you are no longer comparing against Functions, and the comparison belongs in a different article.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hidden bill in App Insights
&lt;/h3&gt;

&lt;p&gt;App Insights bills through Log Analytics (&lt;a href="https://learn.microsoft.com/azure/azure-monitor/logs/cost-logs#application-insights-billing" rel="noopener noreferrer"&gt;App Insights billing&lt;/a&gt;) at &lt;strong&gt;$2.76 per GB&lt;/strong&gt; ingested above the 5 GB free grant per workspace. For most apps the default adaptive sampling at 5 events/second per host keeps ingestion under the grant (&lt;a href="https://learn.microsoft.com/azure/azure-monitor/app/sampling-classic-api" rel="noopener noreferrer"&gt;Sampling in Application Insights&lt;/a&gt;). The failure mode is operational: an engineer disables sampling to chase a bug, forgets to re-enable, and the next month's bill arrives. At 100 RPS unsampled with 10 KB telemetry events, ingestion is 86 GB/day, which is 2.6 TB/month, which is roughly &lt;strong&gt;$7,180/month&lt;/strong&gt; at the PAYG rate.&lt;/p&gt;

&lt;p&gt;The mitigation is a daily cap, set per workspace. The portal default for a workspace-based App Insights resource is 100 GB/day, but resources created via Visual Studio default to 32.3 MB/day (&lt;a href="https://learn.microsoft.com/azure/azure-monitor/logs/daily-cap" rel="noopener noreferrer"&gt;Daily cap&lt;/a&gt;). Whichever number you pick, set it before someone disables sampling.&lt;/p&gt;

&lt;h3&gt;
  
  
  The other hidden bill: the storage account
&lt;/h3&gt;

&lt;p&gt;Every Function App requires a general-purpose storage account (&lt;a href="https://learn.microsoft.com/azure/azure-functions/storage-considerations#storage-account-requirements" rel="noopener noreferrer"&gt;Storage considerations&lt;/a&gt;). With one or two apps, storage is rounding error. With thirty apps after a W19-style split, it becomes a line item:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Queue triggers&lt;/strong&gt;: every poll is a Class 2 op, roughly one per second when idle. Idle alone is &lt;code&gt;86,400 polls/day * 30 / 10,000 * $0.004 ≈ $1/month per queue&lt;/code&gt;. Real processing adds put + get + delete = 3 ops per message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Durable task hubs&lt;/strong&gt;: orchestration and history tables grow into millions of rows with replays, plus control queues, work-item queues, and instance tables. A busy task hub easily reaches $20-50/month before the orchestrator's compute cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal runtime traffic&lt;/strong&gt;: lease blobs for the scale controller, host locks. Negligible per app, multiplied by N apps it shows up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Microsoft's own guidance is to put each app on its own storage account, especially for Durable Functions and Event Hubs triggers. That doubles or triples your storage line item, and it is still the right call.&lt;/p&gt;

&lt;h3&gt;
  
  
  People-time
&lt;/h3&gt;

&lt;p&gt;The cost crossover argument rarely decides the migration. People-time almost always does.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Debugging across N function apps requires correlated query plumbing in App Insights plus a mental model of which app owns which trigger.&lt;/li&gt;
&lt;li&gt;Each function app is its own deployment unit, so coordinated releases need a release pipeline that understands ordering and rollback.&lt;/li&gt;
&lt;li&gt;An on-call alert that fires "queue X is backing up" requires the operator to know which app owns queue X, which trigger, and which version is deployed.&lt;/li&gt;
&lt;li&gt;Configuration drift compounds with app count, as the previous section already showed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cognitive load scales super-linearly with app count. It shows up as slower MTTR, not as a Functions bill line item, and that is exactly why it stays invisible until the team is exhausted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the decision: stay, refactor, or migrate
&lt;/h2&gt;

&lt;p&gt;Most "outgrowing Functions" complaints are organisational, not technical. Apply the W19 split-apps approach first, then revisit. The four concrete signals that say you can stay: no timeout pressure (longest job under half the plan limit), no memory pressure (peak under 60% of the instance ceiling), SNAT and connection issues fixed by &lt;code&gt;IHttpClientFactory&lt;/code&gt;, function-to-feature ratio under 3:1. If all four hold, the platform is not the problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Refactor within Functions
&lt;/h3&gt;

&lt;p&gt;This is W19 territory: split into multiple apps, extract a shared library, isolate triggers by scaling profile. If you are on Consumption, the next step before Premium is Flex Consumption (per-function scaling, longer timeouts, larger SKUs). The caveat from the cost section bears repeating: Flex's 1-second billing floor punishes sub-second functions. Verify on real workload before committing.&lt;/p&gt;

&lt;p&gt;For workloads that bump the timeout but can be split, the checkpoint pattern from the Performance walls section keeps you on Functions: chunk, process, commit cursor, repeat. That keeps the trigger ergonomics, the deployment unit, and the scale controller. The cost is one cursor blob per active batch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Extract specific functions
&lt;/h3&gt;

&lt;p&gt;The middle ground. The function app stays, and one or two functions move out, usually because they hit a wall the rest of the app does not.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Long-running jobs.&lt;/strong&gt; Hosted service in App Service, or a Container Apps job. Same business logic, no per-invocation cap. The &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/MigrationDemo" rel="noopener noreferrer"&gt;companion sample&lt;/a&gt; shows the same payment-settlement workload as a &lt;code&gt;BackgroundService&lt;/code&gt; next to its Function App original. The diff is the hosting wrapper, not the algorithm:&lt;br&gt;
&lt;/p&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;SettlementWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;QueueClient&lt;/span&gt; &lt;span class="n"&gt;queueClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IPaymentSettler&lt;/span&gt; &lt;span class="n"&gt;settler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;QueueOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;queueOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SettlementWorkerStatus&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;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SettlementWorker&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="n"&gt;BackgroundService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;queueClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateIfNotExistsAsync&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;stoppingToken&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="n"&gt;stoppingToken&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="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;queueClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReceiveMessagesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;maxMessages&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;MaxBatchMessages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;visibilityTimeout&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="n"&gt;_options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VisibilityTimeoutSeconds&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;stoppingToken&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;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;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;_options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IdlePollingDelayMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;in&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;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ProcessAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="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;p&gt;Same &lt;code&gt;IPaymentSettler&lt;/code&gt; from the shared library, same queue, no per-invocation timeout. The trade is paying for an always-on worker (App Service) or accepting cold-start on first replica scale-out (Container Apps with KEDA).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stateful workflows hitting Durable limits.&lt;/strong&gt; Cross-app sub-orchestration is impossible, and shared task hub contention is real. Logic Apps Standard or a hosted workflow engine (Temporal, Elsa, Conductor) trades the binding ergonomics for a workflow surface that scales the way Durable does not.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CPU- or memory-bound work above EP3.&lt;/strong&gt; Container Apps with the Dedicated workload profile, or AKS if Kubernetes is already a platform decision in your org.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Full migration
&lt;/h3&gt;

&lt;p&gt;Rare. Criteria: timeout + memory + connection + cost crossover all present, all blocking, and the W19 organisational fixes have been applied without relief. The migration path is contract-first: extract HTTP triggers into thin wrappers, push business logic into testable libraries (a &lt;code&gt;*.Core&lt;/code&gt; project consumed by both the Function App and the destination host), then move binding by binding.&lt;/p&gt;

&lt;p&gt;The companion sample lays this out concretely. &lt;code&gt;Settlement.Core&lt;/code&gt; is consumed unchanged by &lt;code&gt;Settlement.FunctionApp&lt;/code&gt; (the timeout-wall starting point), &lt;code&gt;Settlement.AppService&lt;/code&gt; (always-on, adds HTTP endpoints), and &lt;code&gt;Settlement.ContainerApp&lt;/code&gt; (KEDA-scaled, scale-to-zero, no web host). The diffs are pure hosting concerns. The algorithm and the contract are identical. Reading the three side by side makes the migration question stop being abstract: you are pricing one wrapper against another, not rewriting the workload.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decision matrix
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh7mwnlndrv0dgfd6ubed.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh7mwnlndrv0dgfd6ubed.png" alt="Outgrowing Functions: signal-by-signal decision matrix" width="800" height="580"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The cost crossover at the bottom of the table is the one most teams reach for first. The decision rarely turns on it. It turns on people-time and on whether the abstraction is fighting your design or supporting it. When the abstraction is supporting the design, the right answer is almost always "stay and clean up the wiring."&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The four signals from the opening map to the four sections you just read. Timeouts, memory, sockets, and file system are the platform telling you the workload is too big. Sub-orchestration sprawl, session-lock contention, and high function-to-feature ratios are your team telling you the deployment unit is too big. Sequential chains, shared schemas, and poison loops are the topology making both worse. The cost crossover decides whether the right move is a different plan, a different host, or a different problem statement. None of the signals on its own says "migrate." Two of them at once says "look harder." Three of them blocking says "the abstraction is no longer paying its freight."&lt;/p&gt;

&lt;p&gt;When you last looked at outgrowing Functions, did you stay and refactor, or did you extract the workload to a different host?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Azure Functions Beyond the Basics&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;Continues from &lt;a href="https://dev.to/martin_oehlert/series/38960"&gt;Azure Functions for .NET Developers&lt;/a&gt; (Parts 1-9)&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: &lt;a href="https://dev.to/martin_oehlert/running-azure-functions-in-docker-why-and-how-1hal"&gt;Running Azure Functions in Docker: Why and How&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 2: &lt;a href="https://dev.to/martin_oehlert/docker-pitfalls-i-hit-and-how-to-avoid-them-2395"&gt;Docker Pitfalls I Hit (And How to Avoid Them)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 3: &lt;a href="https://dev.to/martin_oehlert/scaling-azure-functions-consumption-vs-premium-vs-dedicated-2gm"&gt;Scaling Azure Functions: Consumption vs Premium vs Dedicated&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 4: &lt;a href="https://dev.to/martin_oehlert/structuring-complex-function-apps-project-organization-5977"&gt;Structuring Complex Function Apps: Project Organization&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 5: When Azure Functions Fight Back: Signs You've Outgrown Them (this article)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>architecture</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Structuring Complex Function Apps: Project Organization</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 08 May 2026 05:52:21 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/structuring-complex-function-apps-project-organization-5977</link>
      <guid>https://dev.to/martin_oehlert/structuring-complex-function-apps-project-organization-5977</guid>
      <description>&lt;p&gt;Your project is past 15 functions. The next one needs different &lt;code&gt;host.json&lt;/code&gt; concurrency than the rest, and a connection string nobody else in the app should see. Do you split into a second Function App, or change the values and live with the cross-talk? The answer turns on four constraints, none of which is cold start, even though cold start is the reason most teams give first.&lt;/p&gt;

&lt;h2&gt;
  
  
  When one Function App is too many
&lt;/h2&gt;

&lt;p&gt;Microsoft puts the soft cap at &lt;a href="https://learn.microsoft.com/azure/azure-functions/event-driven-scaling#best-practices-and-patterns-for-scalable-apps" rel="noopener noreferrer"&gt;100 event-based triggers per app&lt;/a&gt;. Past that, the scale controller silently stops looking: &lt;a href="https://learn.microsoft.com/azure/azure-functions/event-driven-scaling#understanding-scaling-behaviors" rel="noopener noreferrer"&gt;"When your app has more than 100 event-based triggers, scale decisions are made based on only the first 100 triggers that execute."&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Long before you hit 100, four constraints start to bite:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scale.&lt;/strong&gt; Every trigger in the app shares one scaling decision (with one Flex Consumption exception below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy cadence.&lt;/strong&gt; One PR redeploys every function in the project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blast radius.&lt;/strong&gt; Every function reads every connection string in app settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;host.json&lt;/code&gt; scope.&lt;/strong&gt; One concurrency, timeout, and retry setting for the whole app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The official guidance punts past that: &lt;a href="https://learn.microsoft.com/azure/azure-functions/performance-reliability#function-organization-best-practices" rel="noopener noreferrer"&gt;"It's hard to say how many functions should be in a single app, which depends on your particular workload."&lt;/a&gt; That's the honest answer, but the four constraints above are what you're actually weighing when you get to "particular workload."&lt;/p&gt;

&lt;h2&gt;
  
  
  Monolith vs multiple Function Apps
&lt;/h2&gt;

&lt;p&gt;Each of the four constraints has a doc-citable behaviour behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scale.&lt;/strong&gt; On Consumption and Premium, the app is the scale unit: &lt;a href="https://learn.microsoft.com/azure/azure-functions/event-driven-scaling" rel="noopener noreferrer"&gt;"all functions within a function app that share resources in an instance are scaled at the same time."&lt;/a&gt; One spike on a Service Bus trigger pulls every HTTP endpoint along for the ride, even if those endpoints are idle.&lt;/p&gt;

&lt;p&gt;Flex Consumption is the exception. It scales &lt;a href="https://learn.microsoft.com/azure/azure-functions/event-driven-scaling#per-function-scaling" rel="noopener noreferrer"&gt;per trigger group&lt;/a&gt;: HTTP triggers share one set of instances, Service Bus / Event Hubs / Storage share another, Durable Functions share another. On Flex, splitting an app with one Service Bus trigger and one HTTP trigger gets you almost nothing the platform doesn't already give you. On Consumption or Premium, splitting is the only way to get that behaviour at all.&lt;/p&gt;

&lt;p&gt;The scale-out clock matters too: &lt;a href="https://learn.microsoft.com/azure/azure-functions/event-driven-scaling#understanding-scaling-behaviors" rel="noopener noreferrer"&gt;"For HTTP triggers, new instances are allocated, at most, once per second. For non-HTTP triggers, new instances are allocated, at most, once every 30 seconds."&lt;/a&gt; A monolith cannot speed that up. Splitting can give a latency-sensitive HTTP path its own scale clock independent of a slow-scaling queue trigger sitting next to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy cadence.&lt;/strong&gt; &lt;a href="https://learn.microsoft.com/azure/azure-functions/performance-reliability#function-organization-best-practices" rel="noopener noreferrer"&gt;"All functions in your local project are deployed together as a set of files."&lt;/a&gt; That's fine when one team owns one repo, ships everything together, and a bad deploy on Function A doesn't block fixing Function B. The day either of those stops being true, the monolith is in your way. Slot deployments, canary releases, and per-function rollback all assume separate apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blast radius.&lt;/strong&gt; Every function in the app reads every connection string and every Key Vault reference in app settings. Microsoft writes this as a security practice, not a sizing one: &lt;a href="https://learn.microsoft.com/azure/azure-functions/performance-reliability#function-organization-best-practices" rel="noopener noreferrer"&gt;"Connection strings and other credentials stored in application settings gives all of the functions in the function app the same set of permissions in the associated resource. Consider minimizing the number of functions with access to specific credentials by moving functions that don't use those credentials to a separate function app."&lt;/a&gt; A single high-privilege connection string contaminates the whole app. The mitigation is a separate Function App with its own managed identity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;host.json&lt;/code&gt; scope.&lt;/strong&gt; Settings in &lt;code&gt;host.json&lt;/code&gt; apply to every function in the app within an instance. The worked example: &lt;a href="https://learn.microsoft.com/azure/azure-functions/performance-reliability#scalability-best-practices" rel="noopener noreferrer"&gt;"if you had a function app with two HTTP functions and &lt;code&gt;maxConcurrentRequests&lt;/code&gt; set to 25, a request to either HTTP trigger would count towards the shared 25 concurrent requests."&lt;/a&gt; When two triggers need different concurrency budgets, you pick the looser one and accept the cross-talk, or you split the app. There is no third option.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cold start: how much does function count actually cost?
&lt;/h3&gt;

&lt;p&gt;The docs warn that "having too many functions within a function app can lead to slower startup of your app on new instances," but Microsoft publishes no numbers, so the warning sits as a vibe. I wanted to see when it starts to bite.&lt;/p&gt;

&lt;p&gt;Three .NET 10 isolated worker apps, identical except for function count: 5, 15, and 30 minimal HTTP endpoints. No DI, no shared state, no external dependencies. For each app I spawn &lt;code&gt;func start&lt;/code&gt; from a clean build, poll the first endpoint until it returns a 200, record wall-clock time, then kill and repeat ten times. Median across the ten runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Functions&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;p10&lt;/th&gt;
&lt;th&gt;p90&lt;/th&gt;
&lt;th&gt;Δ vs 5-fn baseline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;1528 ms&lt;/td&gt;
&lt;td&gt;1481 ms&lt;/td&gt;
&lt;td&gt;1673 ms&lt;/td&gt;
&lt;td&gt;+0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;1552 ms&lt;/td&gt;
&lt;td&gt;1536 ms&lt;/td&gt;
&lt;td&gt;1728 ms&lt;/td&gt;
&lt;td&gt;+24 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;1548 ms&lt;/td&gt;
&lt;td&gt;1542 ms&lt;/td&gt;
&lt;td&gt;1561 ms&lt;/td&gt;
&lt;td&gt;+20 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The delta is noise. Going from 5 functions to 30 cost 20 ms median on my machine, well inside the ~200 ms run-to-run variance on the same project. At this scale, function count is not where your cold-start budget goes.&lt;/p&gt;

&lt;p&gt;That changes the case for splitting. If you're splitting a 30-function app &lt;em&gt;because of cold start&lt;/em&gt;, the data isn't with you. The reasons that hold up are different: scaling (one trigger spike pulls everyone with it on Consumption and Premium), deployment cadence (one PR redeploys all of it), blast radius (every function in the app can read every connection string and every Key Vault reference in app settings), and &lt;code&gt;host.json&lt;/code&gt; scope (one concurrency / timeout setting for the lot). Cold start is the argument that sounds intuitive and turns out not to land.&lt;/p&gt;

&lt;p&gt;The absolute ~1.5 s number above includes Core Tools overhead, .NET runtime startup, and host metadata loading. Don't extrapolate it to Azure platform cold start. That's a different constant on top. The delta column is what scales with function count, and on this machine it's noise.&lt;/p&gt;

&lt;p&gt;The methodology, the three apps, and a script you can run on your own machine to reproduce or extend the measurement (more iterations, more functions, your machine, your runtime): &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/ColdStartBenchmark" rel="noopener noreferrer"&gt;&lt;code&gt;ColdStartBenchmark/&lt;/code&gt;&lt;/a&gt; in the companion repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing code without copy-paste
&lt;/h2&gt;

&lt;p&gt;Two Function Apps in one solution want the same &lt;code&gt;Order&lt;/code&gt; record, the same &lt;code&gt;OrderValidator&lt;/code&gt;, the same &lt;code&gt;IOrderStore&lt;/code&gt; abstraction. Project reference is the default. You reach for an internal NuGet package only when project reference stops being enough.&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;!-- OrderProcessor.Core/OrderProcessor.Core.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"&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;RootNamespace&amp;gt;&lt;/span&gt;OrderProcessor.Core&lt;span class="nt"&gt;&amp;lt;/RootNamespace&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;"Microsoft.Extensions.DependencyInjection.Abstractions"&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;"Microsoft.Extensions.Logging.Abstractions"&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;"Microsoft.Extensions.Options"&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;Two things are missing on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;TargetFramework&lt;/code&gt;&lt;/strong&gt; because the solution sets it centrally via &lt;code&gt;Directory.Build.props&lt;/code&gt;. Whichever TFM the Function Apps use, this library matches. Microsoft's &lt;a href="https://learn.microsoft.com/dotnet/standard/library-guidance/cross-platform-targeting#net-and-net-standard-targets" rel="noopener noreferrer"&gt;library guidance&lt;/a&gt; says: "DO start with including a &lt;code&gt;net8.0&lt;/code&gt; target or later for new libraries." If every consumer is .NET 10 isolated worker, target &lt;code&gt;net10.0&lt;/code&gt; and skip the &lt;code&gt;netstandard2.0&lt;/code&gt; ceremony.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;Microsoft.Azure.Functions.Worker.*&lt;/code&gt; packages.&lt;/strong&gt; The library has zero dependency on the Functions SDK. An ASP.NET Core API or a console app could consume it without dragging in the worker host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second rule is the one that bites. The moment you put a &lt;code&gt;[QueueTrigger]&lt;/code&gt; attribute or a &lt;code&gt;[ServiceBusOutput]&lt;/code&gt; binding on a class in the shared library, you've forced &lt;code&gt;Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues&lt;/code&gt; (or the Service Bus equivalent) onto every consumer. A non-Functions consumer can no longer use the library without dragging in the worker SDK.&lt;/p&gt;

&lt;p&gt;Trigger and binding attributes belong with the function class, in the Function App project. Models, validators, business services, and DI extensions belong in the shared library. The &lt;code&gt;Functions/&lt;/code&gt; folder in each app holds the trigger code. The shared library holds everything else.&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;!-- OrderProcessor.Http/OrderProcessor.Http.csproj --&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;FrameworkReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"Microsoft.AspNetCore.App"&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;"Microsoft.Azure.Functions.Worker"&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;"Microsoft.Azure.Functions.Worker.Sdk"&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;"Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore"&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;"..\OrderProcessor.Core\OrderProcessor.Core.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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTP app pulls in &lt;code&gt;Http.AspNetCore&lt;/code&gt;. The Queue app pulls in &lt;code&gt;Storage.Queues&lt;/code&gt; instead. Both reference &lt;code&gt;Core&lt;/code&gt; for shared code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The static-client exception
&lt;/h3&gt;

&lt;p&gt;The "no statics in shared libraries" rule has a documented exception: connection-bearing clients. Microsoft's &lt;a href="https://learn.microsoft.com/azure/azure-functions/manage-connections#static-clients" rel="noopener noreferrer"&gt;connection management guidance&lt;/a&gt; is explicit: "Do create a single, static client that every function invocation can use. Consider creating a single, static client in a shared helper class if different functions use the same service."&lt;/p&gt;

&lt;p&gt;The rule is more precise than "no statics": no static &lt;em&gt;application state&lt;/em&gt; that assumes single-instance execution. &lt;code&gt;HttpClient&lt;/code&gt;, &lt;code&gt;BlobServiceClient&lt;/code&gt;, &lt;code&gt;CosmosClient&lt;/code&gt;, &lt;code&gt;ServiceBusClient&lt;/code&gt; are intended to be shared statics. Counters, caches, and "current user" fields are not.&lt;/p&gt;

&lt;h3&gt;
  
  
  When project reference stops scaling
&lt;/h3&gt;

&lt;p&gt;Project references work until you have more than one solution. The moment a second repo wants &lt;code&gt;OrderProcessor.Core&lt;/code&gt;, you either git-submodule it (don't), copy-paste it (also don't), or publish it as an internal NuGet package and ship versioned releases. &lt;a href="https://learn.microsoft.com/azure/devops/artifacts/concepts/feeds?view=azure-devops" rel="noopener noreferrer"&gt;Azure Artifacts&lt;/a&gt; gives you an org-scoped feed for that, with 2 GiB free. The cost is the version-drift problem you didn't have before: now Function App A can sit on Core 1.4 while Function App B is on Core 1.7.&lt;/p&gt;

&lt;p&gt;The default for a single solution is project reference. Switch to internal NuGet when (a) two repos consume the library, and (b) you actually need to ship them on different cadences. Anything before that is process where you needed a project reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dependency injection with Keyed Services
&lt;/h2&gt;

&lt;p&gt;Two Function Apps want different storage backends. The HTTP app writes through SQL because the read model needs strong consistency. The Queue app reads from Cosmos for bulk reprocessing. Both consume &lt;code&gt;IOrderStore&lt;/code&gt;. .NET 8 added &lt;a href="https://learn.microsoft.com/dotnet/core/extensions/dependency-injection/overview#keyed-services" rel="noopener noreferrer"&gt;Keyed Services&lt;/a&gt; so you don't have to invent a factory, a sentinel type, or a &lt;code&gt;Func&amp;lt;string, IOrderStore&amp;gt;&lt;/code&gt; per backend.&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;namespace&lt;/span&gt; &lt;span class="nn"&gt;OrderProcessor.Core.Stores&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;interface&lt;/span&gt; &lt;span class="nc"&gt;IOrderStore&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;Order&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;orderId&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="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;SaveAsync&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;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;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;SqlOrderStore&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;SqlOrderStore&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="n"&gt;IOrderStore&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// SQL implementation&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;CosmosOrderStore&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;CosmosOrderStore&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="n"&gt;IOrderStore&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Cosmos implementation&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both implementations register against the same interface, distinguished by a key:&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;OrderStoreKeys&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;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Sql&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"sql"&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;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Cosmos&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"cosmos"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// OrderProcessor.Core/Services/ServiceCollectionExtensions.cs&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;IServiceCollection&lt;/span&gt; &lt;span class="nf"&gt;AddOrderServices&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;IServiceCollection&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TryAddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderValidator&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

    &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TryAddKeyedSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IOrderStore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SqlOrderStore&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderStoreKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sql&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;TryAddKeyedSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IOrderStore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CosmosOrderStore&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderStoreKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cosmos&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;services&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;Three details in the snippet are load-bearing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The keys are &lt;code&gt;const string&lt;/code&gt; on a static class, not bare string literals at the call site.&lt;/strong&gt; Stringly-typed keys are the failure mode: a typo in &lt;code&gt;[FromKeyedServices("sqll")]&lt;/code&gt; throws &lt;code&gt;InvalidOperationException&lt;/code&gt; at resolve time, not compile time. A typo in &lt;code&gt;OrderStoreKeys.Sql&lt;/code&gt; doesn't compile. The &lt;code&gt;object?&lt;/code&gt; parameter accepts anything that implements &lt;code&gt;Equals&lt;/code&gt; correctly, so enums or typed records work too. &lt;code&gt;const string&lt;/code&gt; constants are the cheapest mitigation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;TryAddKeyed*&lt;/code&gt; rather than &lt;code&gt;AddKeyed*&lt;/code&gt;.&lt;/strong&gt; Every &lt;code&gt;AddKeyed*&lt;/code&gt; call adds a new descriptor, and &lt;code&gt;GetKeyedService&amp;lt;T&amp;gt;&lt;/code&gt; returns the &lt;em&gt;last&lt;/em&gt; registration, silently shadowing earlier ones. Library code that registers default keyed implementations should use &lt;code&gt;TryAddKeyed*&lt;/code&gt; so a consumer can override without ending up with two &lt;code&gt;(IOrderStore, "sql")&lt;/code&gt; registrations and the silent second-wins behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App-specific wiring stays in &lt;code&gt;Program.cs&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;AddOrderServices&lt;/code&gt; is shared across both apps. If &lt;code&gt;OrderProcessor.Http&lt;/code&gt; needs an &lt;code&gt;HttpClient&lt;/code&gt; and &lt;code&gt;OrderProcessor.Queue&lt;/code&gt; needs a &lt;code&gt;ServiceBusClient&lt;/code&gt;, those go in their respective &lt;code&gt;Program.cs&lt;/code&gt; files, not in the shared library.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;Program.cs&lt;/code&gt; pulls it together:&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;// OrderProcessor.Http/Program.cs&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Azure.Functions.Worker.Builder&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.Extensions.DependencyInjection&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.Extensions.Hosting&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;OrderProcessor.Core.Configuration&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;OrderProcessor.Core.Services&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;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="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configure&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderProcessingOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OrderProcessing"&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;AddOrderServices&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FunctionsApplication.CreateBuilder&lt;/code&gt; is the &lt;a href="https://learn.microsoft.com/azure/azure-functions/dotnet-isolated-process-guide#start-up-and-configuration" rel="noopener noreferrer"&gt;recommended builder for new isolated worker projects&lt;/a&gt;. It loads &lt;code&gt;appsettings.json&lt;/code&gt; automatically, where &lt;code&gt;new HostBuilder()&lt;/code&gt; does not. The &lt;code&gt;Configure&amp;lt;OrderProcessingOptions&amp;gt;&lt;/code&gt; line binds an options class for any per-function tunables (retry counts, batch sizes, timeouts) that you'd rather change in app settings than in code. The function injects &lt;code&gt;IOptions&amp;lt;OrderProcessingOptions&amp;gt;&lt;/code&gt; and reads &lt;code&gt;.Value&lt;/code&gt;. Wednesday's tip walks through &lt;code&gt;IOptions&amp;lt;T&amp;gt;&lt;/code&gt; vs &lt;code&gt;IOptionsSnapshot&amp;lt;T&amp;gt;&lt;/code&gt; for the cases where you need values to refresh at runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resolving the right key
&lt;/h3&gt;

&lt;p&gt;The function class takes its &lt;code&gt;IOrderStore&lt;/code&gt; directly on the primary constructor parameter:&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;// OrderProcessor.Http/Functions/CreateOrderFunction.cs&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;CreateOrderFunction&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;CreateOrderFunction&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;OrderValidator&lt;/span&gt; &lt;span class="n"&gt;validator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;FromKeyedServices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderStoreKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sql&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;IOrderStore&lt;/span&gt; &lt;span class="n"&gt;primaryStore&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;CreateOrder&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;CreateOrder&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;"orders"&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;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;FromBody&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;CreateOrderRequest&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="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="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;request&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;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="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pending&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;validation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Validate&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;validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsValid&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;new&lt;/span&gt; &lt;span class="nf"&gt;BadRequestObjectResult&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;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;primaryStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveAsync&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;cancellationToken&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;CreatedResult&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="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="s"&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;}&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;[FromKeyedServices]&lt;/code&gt; is &lt;code&gt;AttributeUsage = AttributeTargets.Parameter&lt;/code&gt;, which is exactly where primary constructors put their parameters. No backing fields to assign, no field-name shuffle to make the key visible to the function method.&lt;/p&gt;

&lt;p&gt;A second function in the same app can resolve a different key:&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;GetOrderFunction&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;GetOrderFunction&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="nf"&gt;FromKeyedServices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderStoreKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cosmos&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;IOrderStore&lt;/span&gt; &lt;span class="n"&gt;readStore&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;GetOrder&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;GetOrder&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;"get"&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;"orders/{orderId}"&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;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;orderId&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;Order&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;readStore&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;orderId&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;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="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;NotFoundResult&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;OkObjectResult&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CreateOrderFunction&lt;/code&gt; writes through SQL. &lt;code&gt;GetOrderFunction&lt;/code&gt; reads from Cosmos. Same Function App, same &lt;code&gt;IOrderStore&lt;/code&gt; interface, two implementations resolved by key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two breaking changes worth knowing
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;.NET 9 changed missing-key behaviour.&lt;/strong&gt; Before .NET 9, &lt;code&gt;[FromKeyedServices("xyz")]&lt;/code&gt; would silently fall back to an unkeyed &lt;code&gt;IService&lt;/code&gt; registration if no &lt;code&gt;"xyz"&lt;/code&gt; key existed. As of &lt;a href="https://learn.microsoft.com/dotnet/core/compatibility/core-libraries/9.0/non-keyed-params" rel="noopener noreferrer"&gt;.NET 9&lt;/a&gt;, it throws &lt;code&gt;InvalidOperationException&lt;/code&gt; at resolve time. That's an improvement (typos no longer succeed quietly) and another reason to keep keys as compile-time constants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;.NET 10 changed &lt;code&gt;KeyedService.AnyKey&lt;/code&gt; semantics.&lt;/strong&gt; &lt;a href="https://learn.microsoft.com/dotnet/core/compatibility/extensions/10.0/getkeyedservice-anykey" rel="noopener noreferrer"&gt;Two related changes&lt;/a&gt;: &lt;code&gt;GetKeyedService(provider, type, KeyedService.AnyKey)&lt;/code&gt; now throws instead of resolving an arbitrary registration, and &lt;code&gt;GetKeyedServices(provider, type, KeyedService.AnyKey)&lt;/code&gt; no longer returns services that were themselves registered against &lt;code&gt;AnyKey&lt;/code&gt;. If you have library code using &lt;code&gt;AnyKey&lt;/code&gt; for "give me whatever's registered", check it before the .NET 10 upgrade.&lt;/p&gt;

&lt;p&gt;.NET 10 also added &lt;a href="https://learn.microsoft.com/dotnet/api/microsoft.extensions.dependencyinjection.fromkeyedservicesattribute.lookupmode?view=net-10.0-pp" rel="noopener noreferrer"&gt;&lt;code&gt;FromKeyedServicesAttribute.LookupMode&lt;/code&gt;&lt;/a&gt; so a keyed service's transitive dependencies can inherit the parent's key automatically. Useful for keyed graphs (a &lt;code&gt;KeyedDataProcessor&lt;/code&gt; whose inner &lt;code&gt;KeyedConnection&lt;/code&gt; should match the outer key), but skip it on the first pass. Explicit keys are easier to read.&lt;/p&gt;

&lt;p&gt;The full working code lives in &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/ProjectOrganizationDemo" rel="noopener noreferrer"&gt;&lt;code&gt;ProjectOrganizationDemo/&lt;/code&gt;&lt;/a&gt; in the companion repo: shared &lt;code&gt;Core&lt;/code&gt; library, two Function Apps, both keyed implementations, and a &lt;code&gt;local.settings.json.example&lt;/code&gt; for each app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Folder structure that holds up at 30 functions
&lt;/h2&gt;

&lt;p&gt;Microsoft's official .NET isolated-worker sample groups files by trigger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;samples/FunctionApp/
├── FunctionApp.csproj
├── Program.cs
├── host.json
├── local.settings.json
├── HttpTriggerSimple/
├── HttpTriggerWithBlobInput/
├── HttpTriggerWithCancellation/
├── HttpTriggerWithDependencyInjection/
├── HttpTriggerWithMultipleOutputBindings/
└── QueueTrigger/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the actual tree of &lt;a href="https://github.com/Azure/azure-functions-dotnet-worker/tree/main/samples/FunctionApp" rel="noopener noreferrer"&gt;&lt;code&gt;Azure/azure-functions-dotnet-worker/samples/FunctionApp&lt;/code&gt;&lt;/a&gt;. It's a per-trigger demo: one folder per function feature, no shared layers. It works for the sample because each folder is self-contained.&lt;/p&gt;

&lt;p&gt;It stops working at fifteen functions. By then a &lt;code&gt;Services/&lt;/code&gt;, a &lt;code&gt;Models/&lt;/code&gt;, and a couple of cross-cutting concerns have accumulated, and the per-trigger folder layout has nowhere natural to put them. You either bolt &lt;code&gt;Services/&lt;/code&gt; on next to the trigger folders, or you switch to a layered convention.&lt;/p&gt;

&lt;p&gt;The convention this article uses (and the one the companion sample uses) is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OrderProcessor.Http/
├── OrderProcessor.Http.csproj
├── Program.cs
├── host.json
├── local.settings.json
├── Functions/                # trigger classes only
│   ├── CreateOrderFunction.cs
│   └── GetOrderFunction.cs
├── Models/                   # app-specific request/response types
│   └── CreateOrderRequest.cs
└── Infrastructure/           # middleware, options classes, app-specific clients
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Microsoft does not document this layout for C# isolated worker. &lt;strong&gt;It's a community convention, not Microsoft guidance.&lt;/strong&gt; The closest official endorsement is the &lt;a href="https://learn.microsoft.com/azure/azure-functions/functions-reference-node#folder-structure" rel="noopener noreferrer"&gt;Node.js v4 model docs&lt;/a&gt;, which explicitly recommend a &lt;code&gt;src/functions/&lt;/code&gt; subfolder. The .NET isolated-worker docs are silent on subfolder shape.&lt;/p&gt;

&lt;p&gt;Three rules I follow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Functions/&lt;/code&gt; holds trigger classes only.&lt;/strong&gt; Every file in &lt;code&gt;Functions/&lt;/code&gt; has a &lt;code&gt;[Function]&lt;/code&gt; attribute. If a class has no trigger, it doesn't go here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App-specific code is local. Reusable code goes to &lt;code&gt;Core&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;CreateOrderRequest&lt;/code&gt; is a DTO only the HTTP app sees, so it lives in &lt;code&gt;OrderProcessor.Http/Models/&lt;/code&gt;. &lt;code&gt;Order&lt;/code&gt;, the domain record both apps share, lives in &lt;code&gt;OrderProcessor.Core/Models/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Infrastructure/&lt;/code&gt; is for cross-cutting wiring, not business logic.&lt;/strong&gt; Middleware, options classes, custom logging filters, retry policy setup. The test is "if I deleted this folder, would my domain logic break?" If yes, it belongs in &lt;code&gt;Services/&lt;/code&gt; or &lt;code&gt;Core&lt;/code&gt;. If no, &lt;code&gt;Infrastructure/&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;host.json&lt;/code&gt; and &lt;code&gt;local.settings.json&lt;/code&gt; stay in the project root. The deployment payload &lt;a href="https://learn.microsoft.com/azure/azure-functions/dotnet-isolated-process-guide#deploy-to-azure-functions" rel="noopener noreferrer"&gt;is explicit&lt;/a&gt; that they sit next to the executable, peer to your code files. There is no clever way to move them.&lt;/p&gt;

&lt;h3&gt;
  
  
  When the project also runs in Aspire
&lt;/h3&gt;

&lt;p&gt;If the Function App is part of a &lt;a href="https://learn.microsoft.com/azure/azure-functions/dotnet-aspire-integration" rel="noopener noreferrer"&gt;.NET Aspire&lt;/a&gt; orchestration, OpenTelemetry, health checks, and resilience defaults move to a separate &lt;code&gt;*.ServiceDefaults&lt;/code&gt; project. The Aspire docs are explicit: &lt;a href="https://learn.microsoft.com/azure/azure-functions/dotnet-isolated-process-guide#logging" rel="noopener noreferrer"&gt;"If your project is part of an Aspire orchestration, it uses OpenTelemetry for monitoring instead. Don't enable direct Application Insights integration within Aspire projects."&lt;/a&gt; When you go Aspire, the &lt;code&gt;Infrastructure/&lt;/code&gt; folder mostly empties into &lt;code&gt;ServiceDefaults&lt;/code&gt;, and &lt;code&gt;Program.cs&lt;/code&gt; becomes one extra &lt;code&gt;builder.AddServiceDefaults()&lt;/code&gt; call.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to split, when to keep together
&lt;/h2&gt;

&lt;p&gt;The four constraints from the opening turn into a checklist. Split when one or more of these flips from "no" to "yes":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Independent scale needs.&lt;/strong&gt; Two triggers in the same app share scaling on Consumption and Premium. If a queue trigger scales to 50 instances during a backlog burn-down, the HTTP endpoint comes along, even when nobody is calling it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Independent deploy cadence.&lt;/strong&gt; A team that wants to canary the orders API without redeploying the inventory worker. A risky change to one function that shouldn't block hotfixing another. Per-slot deployments per app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different &lt;code&gt;host.json&lt;/code&gt;.&lt;/strong&gt; Two HTTP endpoints, one needs &lt;code&gt;maxConcurrentRequests: 25&lt;/code&gt;, the other &lt;code&gt;200&lt;/code&gt;. There is no way to set this per function inside one app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credential boundary.&lt;/strong&gt; A function reads a high-privilege Cosmos connection string. Every other function in the app inherits the same read access. The mitigation is a separate app with its own managed identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test code mixed with prod.&lt;/strong&gt; The &lt;a href="https://learn.microsoft.com/azure/azure-functions/performance-reliability#scalability-best-practices" rel="noopener noreferrer"&gt;scalability guide&lt;/a&gt; says it directly: "If you're using a function app in production, don't add test-related functions and resources to it." Memory is shared inside the app. So is everything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep together when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shared state.&lt;/strong&gt; A function that pre-warms a cache another function reads. They depend on co-location to be cheap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single team, single repo, related triggers.&lt;/strong&gt; Small surface, no cross-team friction, the functions evolve together. The overhead of a second app pays for nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low traffic.&lt;/strong&gt; The app handles three requests a minute. Splitting trades one infrastructure unit for two with no operational gain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two reasons that sound good and are on neither list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cold start.&lt;/strong&gt; The benchmark above shows function count is not where your cold-start budget goes at 5-30 functions per app. If you're splitting for cold start, the data isn't with you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"It feels too big."&lt;/strong&gt; Aesthetic discomfort with a 20-function project is not a constraint. Pick a doc-citable reason from the list above, or accept the discomfort.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The split-or-keep decision belongs to your scaling, deploy, credential, and &lt;code&gt;host.json&lt;/code&gt; constraints. The size of the project is a symptom, not a reason. A 30-function monolith with one team, one deploy cadence, and one credential set is fine. Two functions with conflicting &lt;code&gt;host.json&lt;/code&gt; settings are not.&lt;/p&gt;

&lt;p&gt;Keyed Services on a primary constructor is the cleanest way to handle "two implementations of one interface" once you're inside a single app. A pure shared library (zero &lt;code&gt;Microsoft.Azure.Functions.Worker.*&lt;/code&gt; references) is what keeps those abstractions reusable across multiple Function Apps without dragging the worker SDK into every consumer.&lt;/p&gt;

&lt;p&gt;When you last split a Function App, what was the line that forced it: a &lt;code&gt;host.json&lt;/code&gt; setting that needed two different values, or a credential that shouldn't be visible to every function in the project?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>dotnet</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Scaling Azure Functions: Consumption vs Premium vs Dedicated</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 01 May 2026 03:45:15 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/scaling-azure-functions-consumption-vs-premium-vs-dedicated-2gm</link>
      <guid>https://dev.to/martin_oehlert/scaling-azure-functions-consumption-vs-premium-vs-dedicated-2gm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Azure Functions Beyond the Basics&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;Continues from &lt;a href="https://dev.to/martin_oehlert/series/38960"&gt;Azure Functions for .NET Developers&lt;/a&gt; (Parts 1-9)&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: &lt;a href="https://dev.to/martin_oehlert/running-azure-functions-in-docker-why-and-how-1hal"&gt;Running Azure Functions in Docker: Why and How&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 2: &lt;a href="https://dev.to/martin_oehlert/docker-pitfalls-i-hit-and-how-to-avoid-them-2395"&gt;Docker Pitfalls I Hit (And How to Avoid Them)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3: Scaling Azure Functions: Consumption vs Premium vs Dedicated&lt;/strong&gt; &lt;em&gt;(you are here)&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Your Consumption plan function works fine in dev. Then production traffic arrives, the app scales to zero during a quiet period, and the next request takes 6.8 seconds. The question that follows is always the same: do you switch to Premium at $146/month, or is there something between free-with-cold-starts and always-warm-but-always-billing? Azure Functions has five hosting options now (Consumption, Flex Consumption, Premium, Dedicated, and Container Apps), each with a different billing model and a different answer to that question. This article covers the four App Service-based plans; Container Apps is a different deployment model aimed at containerized microservices. All code samples are in the &lt;a href="https://github.com/MO2k4/azure-functions-samples/tree/main/ScalingDemo" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consumption: true serverless, true cold starts
&lt;/h2&gt;

&lt;p&gt;Microsoft now labels the Consumption plan as "legacy" in its hosting docs and is directing new serverless workloads to Flex Consumption. But Consumption is still where most Functions apps start, and where many should stay. You deploy your code, the platform handles the rest. No servers to manage, no capacity to plan. You pay only when your functions execute.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the scale controller works
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;scale controller&lt;/strong&gt; monitors event rates for each trigger type and decides how many instances to run. Since runtime v4.19.0, it uses &lt;strong&gt;target-based scaling&lt;/strong&gt; by default. The formula is one division:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;desired instances = event source length / target executions per instance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What "event source length" means depends on the trigger. For Storage Queues, it's queue length. For Service Bus, active message count. For Event Hubs, unprocessed events per partition. For Cosmos DB, pending changes in the change feed. The controller reads these signals and adjusts instance count accordingly.&lt;/p&gt;

&lt;p&gt;The controller adds up to four instances at a time. HTTP triggers get new instances at most once per second. Non-HTTP triggers scale at most once every 30 seconds. This is fast enough for gradual traffic ramps but won't help with sudden spikes from zero.&lt;/p&gt;

&lt;h3&gt;
  
  
  Instance limits and billing
&lt;/h3&gt;

&lt;p&gt;Each Consumption instance gets 1.5 GB of memory and one CPU core. The maximum instance count is &lt;strong&gt;200 on Windows&lt;/strong&gt; and &lt;strong&gt;100 on Linux&lt;/strong&gt; (with a 500-instance-per-subscription-per-hour rate limit on Linux).&lt;/p&gt;

&lt;p&gt;Billing has two components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Executions&lt;/strong&gt;: $0.20 per million, with 1,000,000 free per month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execution time&lt;/strong&gt;: $0.000016 per GB-second, with 400,000 GB-seconds free per month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Memory is rounded up to the nearest 128 MB bucket. Execution time rounds to the nearest millisecond, with a minimum billable unit of 128 MB x 100 ms. For a function that runs a few thousand times a day at under a second each, you'll stay well inside the free grant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cold start reality on .NET
&lt;/h3&gt;

&lt;p&gt;After roughly 20 minutes of inactivity, the Consumption plan scales to zero. The next request waits for the platform to provision a fresh instance and start your application from scratch.&lt;/p&gt;

&lt;p&gt;On .NET isolated worker, that cold start typically lands between &lt;strong&gt;2 and 7 seconds&lt;/strong&gt;. Heavy DI registrations push it past 10. The in-process model was faster, but Microsoft is &lt;a href="https://learn.microsoft.com/en-us/azure/azure-functions/migrate-dotnet-to-isolated-model" rel="noopener noreferrer"&gt;retiring it in November 2026&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For timer triggers, queue processors, and other background work, a few seconds of cold start is invisible. For HTTP endpoints that a user is waiting on, it's a problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Consumption can't do
&lt;/h3&gt;

&lt;p&gt;The hard constraints that push teams to other plans:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No VNet integration.&lt;/strong&gt; If your function needs to reach resources inside a virtual network, Consumption is off the table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10-minute execution timeout.&lt;/strong&gt; The default is 5 minutes, configurable to 10. Long-running orchestrations or batch jobs need a different plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No per-function scaling.&lt;/strong&gt; All functions in the app scale together. A chatty timer trigger can cause the platform to allocate instances that your HTTP trigger didn't need.
&amp;lt;!-- Outbound connection limit sourced from Azure Functions networking docs and SNAT port limits. Verify against current docs before publish. --&amp;gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;600 active outbound connections per instance.&lt;/strong&gt; Hit this with parallel HTTP calls to external APIs and requests start failing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Linux Consumption is retiring September 30, 2028.&lt;/strong&gt; Microsoft is directing all new Linux serverless workloads to Flex Consumption. If you're starting a new project on Linux, skip Consumption entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flex Consumption: the middle ground
&lt;/h2&gt;

&lt;p&gt;Flex Consumption is the plan Microsoft now recommends for new serverless workloads. It addresses the two biggest Consumption limitations: no VNet support and no way to reduce cold starts without jumping to a $146/month Premium plan.&lt;/p&gt;

&lt;p&gt;The plan scales to zero like Consumption, but adds &lt;strong&gt;always-ready instances&lt;/strong&gt; that you can configure to stay warm. It supports &lt;strong&gt;VNet integration&lt;/strong&gt; out of the box. And it scales to &lt;strong&gt;1,000 instances&lt;/strong&gt; instead of Consumption's 200.&lt;/p&gt;

&lt;h3&gt;
  
  
  Always-ready instances vs on-demand
&lt;/h3&gt;

&lt;p&gt;By default, Flex Consumption behaves like regular Consumption: zero instances when idle, on-demand instances when events arrive. The difference is you can configure &lt;strong&gt;always-ready instances&lt;/strong&gt; that stay running regardless of traffic.&lt;/p&gt;

&lt;p&gt;Always-ready instances are assigned to &lt;strong&gt;scale groups&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;http&lt;/code&gt;: all HTTP and SignalR triggers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;durable&lt;/code&gt;: orchestration, activity, and entity triggers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;blob&lt;/code&gt;: Event Grid-based blob triggers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;function:&amp;lt;FUNCTION_NAME&amp;gt;&lt;/code&gt;: a specific function&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Setting always-ready to 2 for the &lt;code&gt;http&lt;/code&gt; group keeps two instances permanently running for HTTP functions. Those handle traffic first. If demand exceeds their capacity, the platform adds on-demand instances on top.&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 scale config &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; my-rg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; my-func-app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--always-ready&lt;/span&gt; &lt;span class="nv"&gt;http&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On-demand instances scale to zero when idle. Always-ready instances are billed continuously whether they're executing functions or not. If you enable zone redundancy, the minimum is 2 always-ready instances per group.&lt;/p&gt;

&lt;h3&gt;
  
  
  Billing: per-second, not per-execution
&lt;/h3&gt;

&lt;p&gt;Flex Consumption bills differently from Consumption. Instead of per-execution pricing with sampled memory, you choose a &lt;strong&gt;fixed instance size&lt;/strong&gt; upfront and pay per GB-second of active execution time:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzlhd0ijfizkni34vn4mv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzlhd0ijfizkni34vn4mv.png" alt="Flex Consumption instance sizes and HTTP concurrency" width="484" height="174"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On-demand rates are $0.000026 per GB-second and $0.40 per million executions. The monthly free grant is smaller than Consumption: 250,000 executions and 100,000 GB-seconds (compared to Consumption's 1,000,000 and 400,000).&lt;/p&gt;

&lt;p&gt;Always-ready instances have a separate billing structure with no free grant. The baseline (idle) rate is $0.000004 per GB-second, roughly 6.5x cheaper than the on-demand execution rate. When always-ready instances are actively executing, the execution time rate is $0.000016 per GB-second (the same as Consumption's rate, and cheaper than on-demand).&lt;/p&gt;

&lt;p&gt;The minimum billable execution is 1,000 ms (1 second). After that, billing rounds to the nearest 100 ms. This is less granular than Consumption's per-millisecond rounding, so very fast functions (under 100 ms) cost relatively more on Flex.&lt;/p&gt;

&lt;p&gt;Each instance also gets an extra 272 MB platform buffer that isn't billed. This is memory the Functions host and worker process use, not your function code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scale behavior
&lt;/h3&gt;

&lt;p&gt;Flex Consumption scales per function by trigger type. HTTP and SignalR triggers scale together. Durable Functions triggers scale together. Blob triggers (Event Grid source) scale together. Everything else scales independently per function. This fixes a real problem from Consumption, where a noisy timer trigger could cause unnecessary instance allocation for your HTTP functions.&lt;/p&gt;

&lt;p&gt;Maximum instances: &lt;strong&gt;1,000&lt;/strong&gt; (default limit is 100, configurable via CLI). All Flex Consumption apps in a subscription and region share a &lt;strong&gt;regional quota of 250 cores&lt;/strong&gt; by default. The formula: instances x cores per instance (0.25 for 512 MB, 1 for 2,048 MB, 2 for 4,096 MB). One app running 1,000 instances at 512 MB consumes the entire quota (1,000 x 0.25 = 250 cores). You can request an increase through Azure support, but plan for this limit when running multiple Flex apps in the same region.&lt;/p&gt;

&lt;h3&gt;
  
  
  The constraints to know about
&lt;/h3&gt;

&lt;p&gt;Flex Consumption comes with real limitations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One app per plan.&lt;/strong&gt; Consumption and Premium let you put up to 100 function apps on one plan. Flex is one-to-one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No deployment slots.&lt;/strong&gt; &lt;a href="https://learn.microsoft.com/azure/azure-functions/flex-consumption-site-updates" rel="noopener noreferrer"&gt;Rolling updates&lt;/a&gt; are in public preview as an alternative (zero-downtime deployments without slot swaps), but if your deployment strategy depends on slot swaps, this is a blocker today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux only.&lt;/strong&gt; No Windows support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolated worker only.&lt;/strong&gt; The C# in-process model is not supported.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App init timeout: 30 seconds.&lt;/strong&gt; If your startup code takes longer, the instance fails to initialize. This is not configurable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blob trigger uses Event Grid only.&lt;/strong&gt; The polling-based blob trigger is not available.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Flex Consumption also supports &lt;strong&gt;Azure Files storage mounts&lt;/strong&gt;, letting you mount SMB shares as local directories. This is useful for large binaries, ML models, or shared reference data that you don't want to package in your deployment.&lt;/p&gt;

&lt;p&gt;The Linux-only constraint is less of an issue than it sounds. Linux is where .NET Functions performance is best, and the in-process model (the main reason teams stayed on Windows) is being retired anyway.&lt;/p&gt;

&lt;p&gt;VNet integration works the same way as Premium: subnet delegation to &lt;code&gt;Microsoft.App/environments&lt;/code&gt;, support for private endpoints on storage accounts, Key Vault references over VNet, and native virtual network triggers for non-HTTP event sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Premium: warm instances, guaranteed
&lt;/h2&gt;

&lt;p&gt;Premium (Elastic Premium) is the plan teams reach for when cold starts become unacceptable. It keeps at least one instance running at all times, so your functions never start from zero. That guarantee comes with a price floor: even with zero traffic, you're billed for that minimum instance.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you get for $146/month
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpynjozl03ym87mgxrcm7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpynjozl03ym87mgxrcm7.png" alt="Premium plan SKUs and minimum monthly cost" width="540" height="174"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Billing is per-second based on vCPU-seconds and GB-seconds allocated across instances. No per-execution charge. The EP1 cost breaks down to ~$116.80/vCPU/month + ~$8.32/GB/month at pay-as-you-go rates in US regions. Savings plans (1-year or 3-year commitments) offer roughly 17% off.&lt;/p&gt;

&lt;p&gt;There is no free grant on Premium. From the moment your plan exists, the meter is running.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pre-warmed instances and elastic scale
&lt;/h3&gt;

&lt;p&gt;Premium uses two layers to eliminate cold starts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always-ready instances&lt;/strong&gt; run continuously, regardless of load. You configure how many per app, up to 20. These are billed 24/7, executing or not. If you have multiple function apps on the same Premium plan, the plan's minimum instance count equals the highest always-ready count among all apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prewarmed buffer instances&lt;/strong&gt; sit behind the always-ready pool. The default is 1. When all active instances are handling traffic, the prewarmed instance swaps to active and the platform immediately provisions a new buffer instance to take its place. This means scale-out events get a warm instance instead of a cold one.&lt;/p&gt;

&lt;p&gt;You can define a &lt;strong&gt;warmup trigger&lt;/strong&gt; that runs during the prewarming window. This is where you force-initialize lazy dependencies, open database connections, and prime HTTP connection pools before the instance receives real traffic:&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;Warmup&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;HttpClient&lt;/span&gt; &lt;span class="n"&gt;_httpClient&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;Lazy&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExpensiveAnalyticsClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_analytics&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;Warmup&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;httpClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Lazy&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExpensiveAnalyticsClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_httpClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_analytics&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;analytics&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="s"&gt;"Warmup"&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;Run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;WarmupTrigger&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kt"&gt;object&lt;/span&gt; &lt;span class="n"&gt;warmupContext&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;_analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HttpCompletionOption&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseHeadersRead&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 warmup trigger only fires during scale-out, not on restarts or deployments. It's available on Premium and Flex Consumption, not on the Consumption plan.&lt;/p&gt;

&lt;p&gt;Elastic scale can burst up to &lt;strong&gt;100 instances on Windows&lt;/strong&gt; and &lt;strong&gt;20-100 on Linux&lt;/strong&gt; depending on region. Scaling beyond the minimum is best-effort: the platform allocates instances as fast as it can, but rapid spikes can outpace the prewarmed buffer. When that happens, you get cold starts even on Premium.&lt;/p&gt;

&lt;h3&gt;
  
  
  VNet and other features
&lt;/h3&gt;

&lt;p&gt;VNet integration is supported but not automatic. You configure it at creation time or after, using regional VNet integration with a dedicated subnet (at least 100 available IPs). Private endpoints for inbound traffic are fully supported: you can create a private IP in your VNet and restrict all public access.&lt;/p&gt;

&lt;p&gt;Non-HTTP triggers from VNet-secured resources (Service Bus with private endpoints, for example) require enabling &lt;strong&gt;Runtime Scale Monitoring&lt;/strong&gt;. Without it, the scale controller can't read the event source metrics to decide when to scale.&lt;/p&gt;

&lt;p&gt;Other features that set Premium apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Execution timeout&lt;/strong&gt;: 30 minutes default, configurable to &lt;strong&gt;unbounded&lt;/strong&gt;. Consumption caps at 10 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment slots&lt;/strong&gt;: 3 (including production). Consumption gets 2, Flex gets 0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apps per plan&lt;/strong&gt;: up to 100 function apps on a single Premium plan, sharing the VM pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom Linux container images&lt;/strong&gt; are supported.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When Premium is the wrong call
&lt;/h3&gt;

&lt;p&gt;The most common mistake is jumping to Premium from Consumption solely because of cold starts, without evaluating the alternatives.&lt;/p&gt;

&lt;p&gt;If VNet was your only reason, Flex Consumption now gives you VNet integration with scale-to-zero pricing. No need to pay $146/month for network access.&lt;/p&gt;

&lt;p&gt;If your workload is sporadic (a few hundred invocations a day), the math doesn't work. That function costs pennies on Consumption. On Premium EP1, it costs $146/month regardless of usage. The cold start tax has to be genuinely painful to justify that gap.&lt;/p&gt;

&lt;p&gt;And watch the SKU names. EP1 is Elastic Premium. P1v2 is a Dedicated App Service plan. They behave completely differently: EP1 scales dynamically based on event volume, P1v2 gives you a fixed VM that you scale manually. If your Terraform or Bicep has &lt;code&gt;sku = "P1v2"&lt;/code&gt; and you expected autoscaling, check again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dedicated: fixed compute, fixed bill
&lt;/h2&gt;

&lt;p&gt;The Dedicated plan runs your functions on a standard App Service plan. Same infrastructure, same pricing, same scaling model as a web app. Multiple function apps and web apps can share the same plan.&lt;/p&gt;

&lt;p&gt;This is the plan you pick when you already have App Service infrastructure and want to add functions without creating a separate billing line item.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pricing and compute
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fii7tr5hhn2v83z1jgydt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fii7tr5hhn2v83z1jgydt.png" alt="Dedicated plan SKUs and approximate monthly cost" width="484" height="214"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These are Windows pay-as-you-go prices for US East. Linux is cheaper (roughly 40-50% less for P-series tiers). P1v2 is a previous-generation SKU; Microsoft recommends P1v3 for new deployments.&lt;/p&gt;

&lt;p&gt;Billing is hourly, prorated to the second, per scaled-out instance. Reserved instances (1-year or 3-year) can save up to 55% on Linux. The cost is fixed: you pay the same whether your functions execute zero times or a million times per day.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scaling: you manage it
&lt;/h3&gt;

&lt;p&gt;There is no event-driven scaling on Dedicated. The scale controller that powers Consumption and Premium does not apply here.&lt;/p&gt;

&lt;p&gt;Your options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Manual scale-out&lt;/strong&gt;: set the instance count in the portal or via CLI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rule-based autoscale&lt;/strong&gt; (Standard tier and above): trigger scale-out based on CPU percentage, memory usage, or a schedule&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Autoscale on App Service is slower than Premium's elastic scale. It reacts to sustained load patterns, not individual event bursts. App Service also has a newer "automatic scaling" feature for HTTP-based traffic, but it's &lt;strong&gt;not supported&lt;/strong&gt; when Functions apps are in the plan.&lt;/p&gt;

&lt;p&gt;Maximum instances: 10-30 per plan, or 100 in an App Service Environment (ASE).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always On must be enabled&lt;/strong&gt; in the App Service configuration. Without it, the Functions runtime goes idle after a period of inactivity. Unlike Consumption's scale-to-zero (which the platform manages), an idle Dedicated plan just means your functions silently stop processing. You're still billed for the compute.&lt;/p&gt;

&lt;h3&gt;
  
  
  When Dedicated fits
&lt;/h3&gt;

&lt;p&gt;Dedicated makes sense in specific circumstances:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You already have an underutilized App Service plan.&lt;/strong&gt; Adding functions to existing compute costs nothing extra. The plan is already paid for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You run mixed workloads.&lt;/strong&gt; A web app and a set of background processing functions on the same plan, sharing resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need deployment slots.&lt;/strong&gt; Up to 20, far more than Premium's 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Predictable billing matters more than efficiency.&lt;/strong&gt; Some finance teams prefer a fixed monthly line item over variable serverless costs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The downside is resource contention. If your web app and function app share an S1 instance and the web app spikes, your function throughput drops. There's no isolation within the plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cold start mitigation: what to try first
&lt;/h2&gt;

&lt;p&gt;If you're staying on Consumption or Flex Consumption, cold starts are part of the deal. The strategies below are ordered by impact, highest first. Not all of them apply to every plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. ReadyToRun compilation
&lt;/h3&gt;

&lt;p&gt;The single highest-impact change for .NET cold starts on Consumption and Flex Consumption. Two lines in your &lt;code&gt;.csproj&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;PropertyGroup&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;PublishReadyToRun&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/PublishReadyToRun&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;RuntimeIdentifier&amp;gt;&lt;/span&gt;linux-x64&lt;span class="nt"&gt;&amp;lt;/RuntimeIdentifier&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ReadyToRun pre-compiles your assemblies to native code. The JIT compiler still runs for hot paths at runtime, but the initial load skips the bulk of compilation overhead. In practice, this cuts cold start time roughly in half.&lt;/p&gt;

&lt;p&gt;The trade-off: your deployment package grows 2-3x because the assemblies contain both the native precompiled code and the original IL. For a typical Functions app, that's still well under the 1 GB deployment limit.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Placeholder optimization for .NET isolated
&lt;/h3&gt;

&lt;p&gt;The Functions platform can pre-provision a worker process before your app code loads. Enable it with an app setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WEBSITE_USE_PLACEHOLDER_DOTNETISOLATED=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This requires .NET 6+, a 64-bit process, and the latest Azure Functions SDK versions. The placeholder worker starts the .NET runtime and gets the IPC channel ready while your code is still being loaded, shaving off part of the startup sequence.&lt;/p&gt;

&lt;p&gt;Combine this with ReadyToRun for the best result on Consumption.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Trim your DI registrations
&lt;/h3&gt;

&lt;p&gt;Every service you register in &lt;code&gt;Program.cs&lt;/code&gt; adds to startup time. On a warm instance this is negligible. On a cold start, it compounds.&lt;/p&gt;

&lt;p&gt;Register HTTP clients and SDK clients as &lt;strong&gt;singletons&lt;/strong&gt; so they're constructed once and reused. Wrap expensive dependencies in &lt;code&gt;Lazy&amp;lt;T&amp;gt;&lt;/code&gt; so they're only built when a function actually needs them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="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;HttpClient&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;SocketsHttpHandler&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;PooledConnectionLifetime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&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;BaseAddress&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;"https://api.example.com"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Lazy&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExpensiveAnalyticsClient&lt;/span&gt;&lt;span class="p"&gt;&amp;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="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Lazy&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExpensiveAnalyticsClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;logger&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;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExpensiveAnalyticsClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;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;ExpensiveAnalyticsClient&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="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;PooledConnectionLifetime&lt;/code&gt; on &lt;code&gt;SocketsHttpHandler&lt;/code&gt; rotates DNS entries without disposing the &lt;code&gt;HttpClient&lt;/code&gt; instance. This avoids socket exhaustion (the same problem &lt;code&gt;IHttpClientFactory&lt;/code&gt; solves, but without requiring per-request factory calls in a singleton context).&lt;/p&gt;

&lt;p&gt;Fewer functions per app also helps. Each function adds discovery and registration overhead at startup.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Warmup trigger (Premium and Flex Consumption only)
&lt;/h3&gt;

&lt;p&gt;On plans that support prewarmed instances, the warmup trigger lets you run initialization code before the instance takes real traffic. Force-construct your lazy dependencies, open database connections, and send a throwaway HTTP request to prime the connection pool. See the Premium section above for the code.&lt;/p&gt;

&lt;p&gt;The warmup trigger only fires during scale-out. It does not fire on restarts, deployments, or slot swaps. One per app, and the function must be named &lt;code&gt;Warmup&lt;/code&gt; (case-insensitive).&lt;/p&gt;

&lt;h3&gt;
  
  
  What works where
&lt;/h3&gt;

&lt;p&gt;Not every strategy applies to every plan:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh7wszpvs3yhiekqacu6u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh7wszpvs3yhiekqacu6u.png" alt="Cold start mitigation strategies by plan" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On Dedicated with Always On enabled, cold start is largely a non-issue because instances stay running. On Premium, the always-ready and prewarmed instances handle most of it. ReadyToRun and DI trimming matter most on the serverless plans where instances start from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing a plan: the decision matrix
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F73h99g2hnnl4smb74ovd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F73h99g2hnnl4smb74ovd.png" alt="Decision matrix: hosting plan comparison" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Which plan for which workload
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Consumption&lt;/strong&gt; if your traffic is sporadic, you don't need VNet access, and your users can tolerate a few seconds of cold start. Timer triggers, low-volume queue processors, webhook receivers that aren't latency-sensitive. If your bill on Consumption is under $10/month, there's no reason to move.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flex Consumption&lt;/strong&gt; if you need VNet integration or more than 200 instances, but still want scale-to-zero pricing. Evaluate this before jumping to Premium. The always-ready instances give you a dial between pure serverless and always-warm, and you pay only for what you configure. The constraints (one app per plan, no deployment slots, Linux only) are the deciding factors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Premium EP1&lt;/strong&gt; if your HTTP endpoints are latency-sensitive and cold starts are genuinely costing you users or revenue. Also the right choice for functions that run continuously or need more than 10 minutes of execution time. If you're running multiple function apps, a shared Premium plan can amortize the $146/month minimum across them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dedicated&lt;/strong&gt; if you already have an App Service plan with spare capacity, need more than 3 deployment slots, or your finance team requires a fixed monthly line item. Don't create a Dedicated plan specifically for Functions unless you have a concrete reason: the lack of event-driven scaling makes it the least "serverless" option.&lt;/p&gt;

&lt;h3&gt;
  
  
  The mistake to avoid
&lt;/h3&gt;

&lt;p&gt;The most common path is: start on Consumption, hit cold start problems in production, jump straight to Premium at $146/month. Flex Consumption sits between them and didn't exist when many teams made that decision. If you're evaluating today, Flex Consumption with 1-2 always-ready instances gives you warm starts with scale-to-zero pricing for on-demand instances. Test it before committing to Premium's minimum.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Are you running Consumption or Premium in production right now?&lt;/strong&gt;&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Azure Functions Beyond the Basics&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;Continues from &lt;a href="https://dev.to/martin_oehlert/series/32874"&gt;Azure Functions for .NET Developers&lt;/a&gt; (Parts 1-9)&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: &lt;a href="https://dev.to/martin_oehlert/running-azure-functions-in-docker-why-and-how-1hal"&gt;Running Azure Functions in Docker: Why and How&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 2: &lt;a href="https://dev.to/martin_oehlert/docker-pitfalls-i-hit-and-how-to-avoid-them-2395"&gt;Docker Pitfalls I Hit (And How to Avoid Them)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 3: Scaling Azure Functions: Consumption vs Premium vs Dedicated (this article)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>serverless</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Docker Pitfalls I Hit (And How to Avoid Them)</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 24 Apr 2026 05:39:32 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/docker-pitfalls-i-hit-and-how-to-avoid-them-2395</link>
      <guid>https://dev.to/martin_oehlert/docker-pitfalls-i-hit-and-how-to-avoid-them-2395</guid>
      <description>&lt;p&gt;Your Dockerfile builds, your container starts, and your triggers never fire. The Functions host logs "no functions found" or the container sits idle, processing nothing. The gap between a working image and a working function app is entirely configuration. The runtime needs specific environment variables, the build must publish to the exact path the host expects, and Azurite connections behave differently inside a container network than on localhost. Four walls, four fixes. All code samples are in the &lt;a href="https://github.com/martinoehlert/azure-functions-samples" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 1: Environment Variables That Vanish
&lt;/h2&gt;

&lt;p&gt;Your container starts, the Functions host initializes, and the logs show this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[2026-04-20T08:12:03Z] No job functions found. Try making your job classes and methods public.
[2026-04-20T08:12:03Z] If you're using binding extensions (e.g. Azure Storage, ServiceBus, Timers, etc.)
[2026-04-20T08:12:03Z] make sure you've called the registration method for the extension(s)
[2026-04-20T08:12:03Z] in your startup code
[2026-04-20T08:12:03Z] 0 functions loaded
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You check your code. The classes are public. The methods are public. The bindings are registered. Everything runs fine with &lt;code&gt;func start&lt;/code&gt; on your machine.&lt;/p&gt;

&lt;p&gt;The error is misleading. The real cause: &lt;strong&gt;&lt;code&gt;FUNCTIONS_WORKER_RUNTIME&lt;/code&gt;&lt;/strong&gt; is not set.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the file you trusted does not exist here
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;local.settings.json&lt;/code&gt; is a dev-time convenience. The Azure Functions Core Tools reads it when you run &lt;code&gt;func start&lt;/code&gt; locally. Inside a container, that file is never loaded. The container runtime reads OS environment variables only, and if &lt;code&gt;FUNCTIONS_WORKER_RUNTIME&lt;/code&gt; is missing, the host cannot determine which language worker to start. It discovers zero functions and prints an error that sends you looking at your code instead of your configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;AzureWebJobsStorage&lt;/code&gt;&lt;/strong&gt; is the second variable that catches people. Without it, you get a different failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Value cannot be null. (Parameter 'connectionString')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or worse, no error at all. HTTP triggers still work because they do not require storage. You test with an HTTP endpoint, everything responds, you deploy, and your queue triggers silently never fire. The host needs a storage connection to manage leases, checkpoints, and timer schedules for every non-HTTP trigger type.&lt;/p&gt;

&lt;p&gt;If you set &lt;code&gt;FUNCTIONS_WORKER_RUNTIME&lt;/code&gt; to &lt;code&gt;dotnet&lt;/code&gt; instead of &lt;code&gt;dotnet-isolated&lt;/code&gt;, the host raises &lt;strong&gt;AZFD0013&lt;/strong&gt;: the configured runtime does not match the worker runtime metadata in your published artifacts. Another error that points away from the actual one-word fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  The second trap: &lt;code&gt;.env&lt;/code&gt; files that silently mangle values
&lt;/h3&gt;

&lt;p&gt;Azure Storage connection strings are long. If your &lt;code&gt;.env&lt;/code&gt; file wraps them across lines, Docker Compose silently truncates or corrupts the value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Broken: line-wrapped connection string
AzureWebJobsStorage=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;
  AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;
  BlobEndpoint=http://azurite:10000/devstoreaccount1;
  QueueEndpoint=http://azurite:10001/devstoreaccount1;

# Working: entire value on one line
AzureWebJobsStorage=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;QueueEndpoint=http://azurite:10001/devstoreaccount1;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No warning, no parse error. The value just stops at the first newline.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: separate constants from secrets
&lt;/h3&gt;

&lt;p&gt;Bake values that never change per environment into your Dockerfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; AzureWebJobsScriptRoot=/home/site/wwwroot&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; FUNCTIONS_WORKER_RUNTIME=dotnet-isolated&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass everything else through your Compose file or deployment config:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;functions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;AzureWebJobsStorage=${AzureWebJobsStorage}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;APPLICATIONINSIGHTS_CONNECTION_STRING=${APPLICATIONINSIGHTS_CONNECTION_STRING}&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connection strings and instrumentation keys stay out of the image. They come from &lt;code&gt;.env&lt;/code&gt; locally and from app settings or Key Vault references in production.&lt;/p&gt;

&lt;p&gt;One more if you are deploying custom containers to &lt;strong&gt;App Service&lt;/strong&gt; specifically: set &lt;code&gt;WEBSITES_ENABLE_APP_SERVICE_STORAGE=false&lt;/code&gt;. The default (&lt;code&gt;true&lt;/code&gt;) mounts persistent storage over &lt;code&gt;/home&lt;/code&gt;, which overwrites your published function code at startup. This does not apply to Container Apps, only App Service (&lt;a href="https://github.com/Azure/azure-functions-docker/issues/642" rel="noopener noreferrer"&gt;GitHub issue #642&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: Azurite and Docker Networking
&lt;/h2&gt;

&lt;p&gt;Most tutorials tell you to set &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; to &lt;code&gt;UseDevelopmentStorage=true&lt;/code&gt; and move on. That shorthand expands to a full connection string pointing at localhost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;DefaultEndpointsProtocol&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;http;&lt;/span&gt;
&lt;span class="py"&gt;AccountName&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;devstoreaccount1;&lt;/span&gt;
&lt;span class="py"&gt;AccountKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;&lt;/span&gt;
&lt;span class="py"&gt;BlobEndpoint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:10000/devstoreaccount1;&lt;/span&gt;
&lt;span class="py"&gt;QueueEndpoint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:10001/devstoreaccount1;&lt;/span&gt;
&lt;span class="py"&gt;TableEndpoint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:10002/devstoreaccount1;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See those &lt;code&gt;127.0.0.1&lt;/code&gt; addresses? When Azurite runs on your machine, that works fine. Inside Docker, it breaks.&lt;/p&gt;

&lt;p&gt;Each container runs in its own &lt;strong&gt;network namespace&lt;/strong&gt;. &lt;code&gt;127.0.0.1&lt;/code&gt; inside the functions container refers to the functions container itself, not Azurite. Your function tries to reach storage on its own loopback interface, finds nothing listening, and fails silently or throws a connection error depending on the trigger type.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg9itv2gjh5jrqrfygjnf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg9itv2gjh5jrqrfygjnf.png" alt="Docker networking: broken UseDevelopmentStorage=true vs working explicit service name" width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Docker Compose creates a &lt;strong&gt;shared bridge network&lt;/strong&gt; where each service name resolves to the corresponding container's IP. So the fix is to spell out the full connection string with the Compose service name replacing &lt;code&gt;127.0.0.1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;DefaultEndpointsProtocol&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;http;&lt;/span&gt;
&lt;span class="py"&gt;AccountName&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;devstoreaccount1;&lt;/span&gt;
&lt;span class="py"&gt;AccountKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;&lt;/span&gt;
&lt;span class="py"&gt;BlobEndpoint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;http://azurite:10000/devstoreaccount1;&lt;/span&gt;
&lt;span class="py"&gt;QueueEndpoint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;http://azurite:10001/devstoreaccount1;&lt;/span&gt;
&lt;span class="py"&gt;TableEndpoint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;http://azurite:10002/devstoreaccount1;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;azurite&lt;/code&gt; here is whatever you named the service in your &lt;code&gt;docker-compose.yml&lt;/code&gt;. DNS resolution happens automatically on the Compose network.&lt;/p&gt;

&lt;p&gt;But DNS resolving correctly is not enough. By default, Azurite binds to &lt;code&gt;127.0.0.1&lt;/code&gt; inside its own container, which means it only accepts connections from itself. You need to pass &lt;strong&gt;&lt;code&gt;--blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0&lt;/code&gt;&lt;/strong&gt; so Azurite listens on all interfaces. Without this, the functions container resolves &lt;code&gt;azurite&lt;/code&gt; to the right IP, opens a TCP connection, and gets "Connection refused."&lt;/p&gt;

&lt;p&gt;This pitfall hides well because &lt;strong&gt;HTTP triggers don't need storage&lt;/strong&gt;. You build a function app, add an HTTP trigger, test it in Docker, everything works. Then you add a queue trigger and it silently does nothing: no errors in the console, no messages processed, no indication that storage is unreachable. The function host quietly skips triggers it can't initialize.&lt;/p&gt;

&lt;p&gt;A quick connectivity check saves you the debugging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;functions curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://azurite:10000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Azurite is reachable and bound correctly, you get back a short XML or text response. If you get "Connection refused," check the bind flags. If you get a DNS error, check your service name.&lt;/p&gt;

&lt;p&gt;Part 1 already showed the working Compose file with these settings in place. That is why each piece is there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 3: Debugging a Silent Container
&lt;/h2&gt;

&lt;p&gt;Your container starts, the health check passes, but nothing happens. No HTTP responses, no queue processing, no timer triggers. The logs show the host booting and then silence. This is the most common failure mode with Azure Functions in Docker, and it has six distinct causes. Work through them in order.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc70qsa1xummpjxrl63p5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc70qsa1xummpjxrl63p5.png" alt="Debug decision tree: 6-step diagnostic flow for silent Azure Functions containers" width="800" height="1315"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Check if the host found your functions.&lt;/strong&gt; Run &lt;code&gt;docker logs &amp;lt;container&amp;gt;&lt;/code&gt; and look for the function discovery block near startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host initialized (348ms)
Found the following functions:
  ProcessOrder: timerTrigger
  SubmitOrder: httpTrigger
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see "Host initialized" but zero functions listed, your &lt;strong&gt;&lt;code&gt;AzureWebJobsScriptRoot&lt;/code&gt;&lt;/strong&gt; is wrong or your Dockerfile's &lt;code&gt;WORKDIR&lt;/code&gt; does not point to &lt;code&gt;/home/site/wwwroot&lt;/code&gt;. The host scans that directory for compiled function metadata. If it points somewhere else, it finds nothing and starts successfully with nothing to run. This is the root cause in &lt;a href="https://github.com/Azure/azure-functions-docker/issues/642" rel="noopener noreferrer"&gt;#642&lt;/a&gt; and &lt;a href="https://github.com/Azure/azure-functions-docker/issues/980" rel="noopener noreferrer"&gt;#980&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Check storage connectivity.&lt;/strong&gt; If your functions are listed but triggers never fire, the problem is almost always storage. Look for this error in the logs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Azure Storage connection string named 'Storage' does not exist.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Timer triggers and queue triggers need a valid &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; connection to coordinate leases and checkpoints. HTTP triggers work without storage, so a container that responds to HTTP but ignores everything else is a storage configuration problem. Verify your environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;functions &lt;span class="nb"&gt;env&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;FUNCTIONS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This surfaces &lt;code&gt;FUNCTIONS_WORKER_RUNTIME&lt;/code&gt;, &lt;code&gt;AzureWebJobsStorage&lt;/code&gt;, and any other Functions-specific configuration in the running container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Inspect the container filesystem.&lt;/strong&gt; When functions still do not appear after fixing the script root, the published output may not be where you think it is. Check directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;functions &lt;span class="nb"&gt;ls&lt;/span&gt; /home/site/wwwroot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see your &lt;code&gt;.dll&lt;/code&gt; files, &lt;code&gt;host.json&lt;/code&gt;, &lt;code&gt;function.json&lt;/code&gt; files, and the &lt;code&gt;worker.config.json&lt;/code&gt;. A wrong &lt;code&gt;COPY --from=build&lt;/code&gt; path in a multi-stage Dockerfile is the most common cause: the build stage publishes to &lt;code&gt;/app/publish&lt;/code&gt; but the copy targets &lt;code&gt;/app/out&lt;/code&gt;, and the container starts with an empty wwwroot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Check for assembly conflicts.&lt;/strong&gt; If the host discovers your functions but the worker crashes on invocation, look for &lt;code&gt;FileNotFoundException&lt;/code&gt; referencing assemblies like &lt;code&gt;System.Memory.Data&lt;/code&gt;. This happens when &lt;strong&gt;in-process WebJobs SDK packages&lt;/strong&gt; ship inside an isolated worker image. The host and worker expect different assembly versions, and the loader fails silently until a trigger actually fires. Pin your NuGet package versions to match the host's expectations. See &lt;a href="https://github.com/Azure/azure-functions-docker/issues/1221" rel="noopener noreferrer"&gt;#1221&lt;/a&gt; for the specific version matrix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Attach a debugger.&lt;/strong&gt; When the logs tell you nothing useful, attach directly. VS Code's &lt;code&gt;pipeTransport&lt;/code&gt; configuration or Rider's Docker attach both work. The critical detail: the Functions host and the &lt;strong&gt;isolated worker are separate .NET processes&lt;/strong&gt;. The host is the parent process managing triggers; your code runs in the worker. Attach to the worker PID, not the host PID. If you attach to the host, you will see trigger infrastructure but none of your breakpoints will hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6: Watch for broken image tags.&lt;/strong&gt; Sometimes your container worked yesterday and fails today with no code changes. Base image tag updates can silently break functions. Tag &lt;code&gt;4.33.2&lt;/code&gt; broke function discovery for days before anyone traced it back to the image itself (&lt;a href="https://github.com/Azure/azure-functions-docker/issues/1068" rel="noopener noreferrer"&gt;#1068&lt;/a&gt;). Always pin specific version tags in your Dockerfile. Never use &lt;code&gt;:latest&lt;/code&gt; for the Functions base image in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Other Known Issues
&lt;/h3&gt;

&lt;p&gt;A few problems fall outside the decision tree but will bite you eventually:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No graceful shutdown.&lt;/strong&gt; The default entrypoint &lt;code&gt;start.sh&lt;/code&gt; runs as PID 1 and does not forward SIGTERM to child processes. Your container gets SIGKILL after the orchestrator's grace period expires, which means in-flight executions are terminated without cleanup. This has been open for five years (&lt;a href="https://github.com/Azure/azure-functions-docker/issues/404" rel="noopener noreferrer"&gt;#404&lt;/a&gt;). Workaround: use &lt;code&gt;dumb-init&lt;/code&gt; or a custom entrypoint that traps signals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-root containers break startup.&lt;/strong&gt; The Functions host needs write access to &lt;code&gt;/azure-functions-host&lt;/code&gt; at startup. Running the container as a non-root user fails unless you fix directory permissions in your Dockerfile (&lt;a href="https://github.com/Azure/azure-functions-docker/issues/424" rel="noopener noreferrer"&gt;#424&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Development environment restart loops.&lt;/strong&gt; Setting &lt;code&gt;AZURE_FUNCTIONS_ENVIRONMENT=Development&lt;/code&gt; can trigger the host to restart repeatedly as it watches for file changes that never settle (&lt;a href="https://github.com/Azure/azure-functions-docker/issues/1207" rel="noopener noreferrer"&gt;#1207&lt;/a&gt;). Use &lt;code&gt;Production&lt;/code&gt; or &lt;code&gt;Staging&lt;/code&gt; in Docker unless you specifically need development-mode diagnostics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 4: Image Size and Cold Start
&lt;/h2&gt;

&lt;p&gt;The default Azure Functions base image is 800-900 MB. Add your application code, NuGet packages, and assets, and you're over 1 GB before your first request arrives (&lt;a href="https://github.com/Azure/azure-functions-docker/issues/236" rel="noopener noreferrer"&gt;#236&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;-slim&lt;/code&gt; tags can paradoxically be &lt;em&gt;larger&lt;/em&gt; than the regular tags (&lt;a href="https://github.com/Azure/azure-functions-docker/issues/1230" rel="noopener noreferrer"&gt;#1230&lt;/a&gt;). Always verify with &lt;code&gt;docker images&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Old extension bundles (v2 and v3) still ship inside the v4 images, wasting roughly 429 MB on code your app will never execute (&lt;a href="https://github.com/Azure/azure-functions-docker/issues/880" rel="noopener noreferrer"&gt;#880&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Every optimization here is measurable. Start with &lt;code&gt;docker images&lt;/code&gt; and track the delta.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fheu8msnklq4aaoooc1kt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fheu8msnklq4aaoooc1kt.png" alt="Docker image layers: before (~1 GB+) vs after (~300-400 MB) optimization" width="800" height="332"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  .dockerignore
&lt;/h3&gt;

&lt;p&gt;Without a &lt;code&gt;.dockerignore&lt;/code&gt;, &lt;code&gt;COPY . .&lt;/code&gt; sends your entire working directory to the Docker daemon, including &lt;code&gt;.git/&lt;/code&gt; history and &lt;code&gt;local.settings.json&lt;/code&gt; (which contains connection strings and keys).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bin/
obj/
.git/
.vs/
.vscode/
local.settings.json
node_modules/
*.user
Dockerfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This alone can cut your build context by hundreds of megabytes and prevent secrets from leaking into image layers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer ordering for cache hits
&lt;/h3&gt;

&lt;p&gt;The order of your &lt;code&gt;COPY&lt;/code&gt; instructions determines whether Docker can reuse cached layers. Copy the project file first, restore, then copy everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; MyFunctionApp.csproj .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;dotnet restore

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;dotnet publish &lt;span class="nt"&gt;-c&lt;/span&gt; Release &lt;span class="nt"&gt;-o&lt;/span&gt; /home/site/wwwroot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When only source code changes, the restore layer stays cached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;Step 3/7 : RUN dotnet restore
 ---&amp;gt; Using cache
 ---&amp;gt; 4a8b2c1d3e5f
Step 4/7 : COPY . .
 ---&amp;gt; 9f1e2d3c4b5a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;strong&gt;"Using cache"&lt;/strong&gt; line saves 30-120 seconds per build depending on your package count. Without this ordering, every code change re-downloads every NuGet package.&lt;/p&gt;

&lt;h3&gt;
  
  
  ReadyToRun compilation
&lt;/h3&gt;

&lt;p&gt;Add the &lt;strong&gt;&lt;code&gt;PublishReadyToRun&lt;/code&gt;&lt;/strong&gt; flag to pre-compile IL to native code, reducing JIT time at startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;dotnet publish &lt;span class="nt"&gt;-c&lt;/span&gt; Release &lt;span class="nt"&gt;-o&lt;/span&gt; /home/site/wwwroot &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;-p&lt;/span&gt;:PublishReadyToRun&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;This increases image size slightly but cuts cold start latency by front-loading compilation to build time instead of request time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trimming&lt;/strong&gt; (&lt;code&gt;PublishTrimmed=true&lt;/code&gt;) is the more aggressive option. It strips unused assemblies and can dramatically reduce image size. But the Functions runtime uses reflection to discover your function endpoints, and the trimmer can remove types it considers unreachable. If your functions disappear after trimming, that's why. Use trimming only if you're willing to maintain trim annotations and test thoroughly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cold start: the numbers that matter
&lt;/h3&gt;

&lt;p&gt;On &lt;strong&gt;Azure Container Apps&lt;/strong&gt;, image pull time dominates cold start because the platform scales to zero:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image size&lt;/th&gt;
&lt;th&gt;Pull time&lt;/th&gt;
&lt;th&gt;Total cold start&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;~480 MB&lt;/td&gt;
&lt;td&gt;~20s&lt;/td&gt;
&lt;td&gt;~25-30s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~140 MB&lt;/td&gt;
&lt;td&gt;~7s&lt;/td&gt;
&lt;td&gt;~12-15s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That 13-second pull difference hits every scale-from-zero event. On &lt;strong&gt;Functions Premium&lt;/strong&gt; with always-ready instances, the image is cached on warm infrastructure, so size matters less for latency. It still matters for deployment speed and registry costs.&lt;/p&gt;

&lt;h3&gt;
  
  
  CVE accumulation
&lt;/h3&gt;

&lt;p&gt;Base images are only rebuilt monthly, so vulnerabilities accumulate between rebuilds (&lt;a href="https://github.com/Azure/azure-functions-docker/issues/1185" rel="noopener noreferrer"&gt;#1185&lt;/a&gt;). A multi-stage build where you copy your published output onto a fresh OS base gives you control over patching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mcr.microsoft.com/dotnet/sdk:8.0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="c"&gt;# ... build steps ...&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /home/site/wwwroot /home/site/wwwroot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;docker images&lt;/code&gt; after applying these changes. A starting point of 1 GB+ dropping to 300-400 MB is typical when you combine layer optimization, proper &lt;code&gt;.dockerignore&lt;/code&gt;, and ReadyToRun instead of carrying dead extension bundles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-Deploy Checklist
&lt;/h2&gt;

&lt;p&gt;Save yourself a repeat debugging session. Run through this before every container deployment.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;FUNCTIONS_WORKER_RUNTIME&lt;/code&gt; set to &lt;code&gt;dotnet-isolated&lt;/code&gt; in your container environment, not inherited from &lt;code&gt;local.settings.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; uses explicit endpoint strings with Docker service names instead of &lt;code&gt;UseDevelopmentStorage=true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Connection strings are single-line in &lt;code&gt;.env&lt;/code&gt; files with no line-wrapping&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;docker logs&lt;/code&gt; confirms all expected functions discovered at startup&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;AzureWebJobsScriptRoot&lt;/code&gt; points to &lt;code&gt;/home/site/wwwroot&lt;/code&gt; (verify if using a custom base image)&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;.dockerignore&lt;/code&gt; excludes &lt;code&gt;bin/&lt;/code&gt;, &lt;code&gt;obj/&lt;/code&gt;, &lt;code&gt;.git/&lt;/code&gt;, and &lt;code&gt;local.settings.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] NuGet restore layer cached separately from the source code copy step&lt;/li&gt;
&lt;li&gt;[ ] Base image tag pinned to a specific version, not &lt;code&gt;:latest&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Azurite bound to &lt;code&gt;0.0.0.0&lt;/code&gt; in your Compose configuration&lt;/li&gt;
&lt;li&gt;[ ] Image tested with &lt;code&gt;docker compose up&lt;/code&gt; locally before pushing to any registry&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Which of these four pitfalls cost you the most debugging time: environment variables, Azurite networking, silent startup failures, or image size?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Azure Functions Beyond the Basics&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: &lt;a href="https://dev.to/martin_oehlert/running-azure-functions-in-docker-why-and-how-1hal"&gt;Running Azure Functions in Docker: Why and How&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 2: Docker Pitfalls I Hit (And How to Avoid Them) (this article)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>docker</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>From AZ-204 to AI-200: What Changed and Why It Matters</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 17 Apr 2026 21:18:34 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/from-az-204-to-ai-200-what-changed-and-why-it-matters-5glh</link>
      <guid>https://dev.to/martin_oehlert/from-az-204-to-ai-200-what-changed-and-why-it-matters-5glh</guid>
      <description>&lt;p&gt;Comparing the AZ-204 skill outline against the AI-200 course structure, roughly 60% of AZ-204 carries forward, 25% is dropped entirely, and AI-200 adds about 30% net-new content that AZ-204 never touched. Which side of that split you land on determines whether this transition is a week of review or a month of study. The gap is lopsided enough that you cannot assume existing knowledge transfers cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is AI-200?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Full name:&lt;/strong&gt; AI-200: Azure AI Cloud Developer Associate&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Format:&lt;/strong&gt; Multiple choice, case studies, and scenario-based questions. Based on &lt;a href="https://learn.microsoft.com/en-us/credentials/certifications/certification-exams" rel="noopener noreferrer"&gt;standard Microsoft exam format&lt;/a&gt;: approximately 40-60 questions, 100-minute window, passing score around 700/1000.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeline:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Beta exam: April 2026&lt;/li&gt;
&lt;li&gt;General availability: July 2026 (estimated)&lt;/li&gt;
&lt;li&gt;AZ-204 retirement: &lt;a href="https://learn.microsoft.com/en-us/credentials/certifications/exams/az-204/" rel="noopener noreferrer"&gt;July 31, 2026&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Course:&lt;/strong&gt; The AI-200T00 instructor-led training course maps to seven learning paths that define the exam scope:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Container Hosting: ACR, App Service containers, Container Apps, AKS&lt;/li&gt;
&lt;li&gt;Cosmos DB: NoSQL API with vector search and AI integration&lt;/li&gt;
&lt;li&gt;PostgreSQL Vector Search: pgvector, HNSW indexes, hybrid search&lt;/li&gt;
&lt;li&gt;Azure Managed Redis: data operations, event messaging, vector storage&lt;/li&gt;
&lt;li&gt;Backend Services: Service Bus, Event Grid, Azure Functions&lt;/li&gt;
&lt;li&gt;Secrets and Configuration: Key Vault, managed identities, App Configuration&lt;/li&gt;
&lt;li&gt;Observability: OpenTelemetry, Azure Monitor logs and metrics&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What Carried Forward, What Got Dropped, What's New
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Carried forward (~60% of AZ-204)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most of the backend services survive: Azure Functions (triggers, bindings, Durable Functions), Service Bus, Event Grid, Key Vault, App Configuration, and managed identities all carry over. Several topics are expanded rather than simply retained. Container Apps now gets deeper coverage of KEDA scaling and Dapr integration. Cosmos DB adds vector search on top of the existing NoSQL API. Container Registry picks up ACR Tasks. And managed identities extend to AKS workload identity, which matters because AKS is one of the largest new additions. If you already hold AZ-204, this 60% is review, not new study.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dropped (~25% of AZ-204)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Comparing the &lt;a href="https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/az-204" rel="noopener noreferrer"&gt;AZ-204 study guide&lt;/a&gt; against the AI-200 course outline, seven topics are gone entirely: Blob Storage SDK, MSAL/Identity Platform, Microsoft Graph, SAS tokens, API Management, Event Hubs, and Azure Container Instances. Microsoft removed the CRUD-oriented cloud app topics that do not serve AI workloads. You will not be tested on generating SAS tokens or calling Graph endpoints. If you spent weeks on MSAL token flows for AZ-204, that knowledge still applies to real projects, but it will not appear on AI-200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Brand new (~30% of AI-200)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Based on the &lt;a href="https://learn.microsoft.com/en-us/training/courses/ai-200t00" rel="noopener noreferrer"&gt;AI-200T00 course structure&lt;/a&gt;, AKS spans three modules and likely accounts for an estimated 20-25 exam questions. You need cluster creation with &lt;code&gt;kubectl&lt;/code&gt;, ACR integration via &lt;code&gt;--attach-acr&lt;/code&gt;, and scaling with HPA and cluster autoscaler. Configuration covers ConfigMaps, Secrets, Key Vault CSI Driver, persistent storage (Azure Disk for RWO, Azure Files for RWX), taints and tolerations, and the difference between resource requests and limits. Monitoring adds Container Insights, KQL queries for pod status and events, managed Prometheus, and alerting on OOMKills and resource exhaustion. This is the single largest new topic by question count.&lt;/p&gt;

&lt;p&gt;PostgreSQL with pgvector is where AI-200 tests your understanding of vector databases, covering an estimated 8-11 questions across three modules. The foundation is Flexible Server provisioning with Entra ID auth and PgBouncer connection pooling. From there, you enable the pgvector extension for vector storage and work with distance operators: L2 (&lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt;), cosine (&lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt;), and inner product (&lt;code&gt;&amp;lt;#&amp;gt;&lt;/code&gt;). Batch embedding pipelines use Azure OpenAI to generate vectors at scale. Index optimization is where it gets specific: IVFFlat (partition-based, best under 100K vectors with frequent updates) versus HNSW (graph-based, best above 500K static vectors). Hybrid search combines vector similarity with metadata filters using standard &lt;code&gt;WHERE&lt;/code&gt; clauses.&lt;/p&gt;

&lt;p&gt;Azure Managed Redis replaces the narrow "Azure Cache for Redis" coverage from AZ-204 with a broader scope across an estimated 7-12 questions. Five core data types (strings, hashes, lists, sets, sorted sets) and caching patterns (cache-aside, write-through, write-behind) form the baseline. The exam also tests event messaging: Pub/Sub for fire-and-forget broadcasting versus Streams for durable at-least-once delivery with consumer groups (&lt;code&gt;XREADGROUP&lt;/code&gt;, &lt;code&gt;XACK&lt;/code&gt;). On the Enterprise tier, RediSearch enables vector similarity search using FLAT and HNSW indexes combined with tag, numeric, and text filters.&lt;/p&gt;

&lt;p&gt;OpenTelemetry rounds out the new content with an estimated 5-7 questions. The Azure Monitor OpenTelemetry Distro provides a one-line setup via &lt;code&gt;UseAzureMonitor()&lt;/code&gt;, replacing the proprietary Application Insights SDK. Custom spans use &lt;code&gt;ActivitySource&lt;/code&gt;, custom metrics use &lt;code&gt;Meter&lt;/code&gt; instruments, and W3C TraceContext propagation handles distributed trace correlation across services. Sampling strategies control telemetry volume and cost, which is the kind of production concern the exam now prioritizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Shifts Worth Understanding
&lt;/h2&gt;

&lt;p&gt;The topic changes above are not random. They reflect a different definition of what an Azure developer does.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vector databases replace blob storage
&lt;/h3&gt;

&lt;p&gt;If your application retrieves context from a knowledge base before passing it to a language model, you are building a RAG pipeline, and the retrieval layer runs on one of three backends the exam now tests.&lt;/p&gt;

&lt;p&gt;Cosmos DB supports vector search for globally distributed workloads. PostgreSQL with pgvector handles complex hybrid queries where you combine vector similarity with metadata filters in standard &lt;code&gt;WHERE&lt;/code&gt; clauses. Redis provides low-latency vector retrieval on the Enterprise tier using FLAT and HNSW indexes. Each backend has different index types (IVFFlat, HNSW, FLAT), different distance operators, and different tradeoffs around dataset size and query complexity.&lt;/p&gt;

&lt;p&gt;AZ-204 treated storage as a CRUD problem: upload blobs, set access tiers, generate SAS tokens. AI-200 treats storage as a search problem, and the skill gap between "call a PUT endpoint" and "choose the right index type for 500K embeddings" is not small.&lt;/p&gt;

&lt;h3&gt;
  
  
  AKS moves from infrastructure to developer concern
&lt;/h3&gt;

&lt;p&gt;AI workloads need GPU-enabled nodes isolated from general compute, custom operators, and fine-grained resource limits. Container Apps cannot give you any of that. AI-200 assigns three full modules to AKS: deployment, configuration, and monitoring.&lt;/p&gt;

&lt;p&gt;The exam expects you to select between Azure Disk (RWO) and Azure Files (RWX) storage classes, integrate secrets through the Key Vault CSI Driver, and manage node pools with taints and tolerations. Monitoring means writing KQL queries against Container Insights to diagnose pod failures and resource exhaustion. AZ-204 kept you at the Container Apps level, where Kubernetes was an implementation detail you never touched. That abstraction no longer holds when your inference service needs a dedicated A100 node pool with specific resource requests and limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenTelemetry replaces proprietary instrumentation
&lt;/h3&gt;

&lt;p&gt;Your tracing code now works the same whether telemetry flows to Azure Monitor, Jaeger, or Datadog. The Application Insights SDK locked you into Microsoft's instrumentation API, Microsoft's backend, and Microsoft's query tools. AI-200 replaces that instrumentation layer with &lt;a href="https://opentelemetry.io/" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt;, the CNCF-backed open standard.&lt;/p&gt;

&lt;p&gt;The Azure Monitor OpenTelemetry Distro makes setup a one-liner with &lt;code&gt;UseAzureMonitor()&lt;/code&gt;, but the exam goes deeper. Custom instrumentation means creating spans with &lt;code&gt;ActivitySource&lt;/code&gt; and recording metrics with &lt;code&gt;Meter&lt;/code&gt; instruments. Distributed trace correlation relies on W3C TraceContext headers propagated across service boundaries. Sampling configuration controls telemetry volume, which directly affects cost at scale. Azure Monitor still serves as the analysis backend; what changed is the instrumentation contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for Your Study Plan
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you already hold AZ-204:&lt;/strong&gt; roughly 60% carries forward. Your knowledge of Azure Functions, Service Bus, Event Grid, Key Vault, managed identities, and Cosmos DB basics is still valid. The gap areas are AKS (the largest single investment if you have not worked with Kubernetes), PostgreSQL with pgvector, Azure Managed Redis vector storage and Streams, and OpenTelemetry custom instrumentation. Budget 3-4 weeks of focused study on those new topics, then 1 week reviewing carried-over material to make sure nothing has shifted in scope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you are currently studying for AZ-204:&lt;/strong&gt; you have a decision to make before &lt;a href="https://learn.microsoft.com/en-us/credentials/certifications/exams/az-204/" rel="noopener noreferrer"&gt;July 31, 2026&lt;/a&gt;. If you are close to passing, finish it: the credential stays valid through its &lt;a href="https://learn.microsoft.com/en-us/credentials/certifications/renew" rel="noopener noreferrer"&gt;full renewal cycle&lt;/a&gt;. If you are early in your studies, pivot to AI-200 now and skip the dropped topics entirely. There is no reason to invest time in the Blob Storage SDK, MSAL, Microsoft Graph, API Management, or Event Hubs when those topics will not appear on the replacement exam.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you are starting fresh:&lt;/strong&gt; go directly to AI-200. The AI-200T00 course structure and Microsoft Learn paths give you everything you need; AZ-204 material adds no value at this point.&lt;/p&gt;

&lt;p&gt;The 8-week plan below assumes you are starting from scratch or pivoting from early AZ-204 study:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fazmbeqgvzb5d3hkiqtei.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fazmbeqgvzb5d3hkiqtei.png" alt="8-week AI-200 study plan" width="538" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://learn.microsoft.com/en-us/training/courses/ai-200t00" rel="noopener noreferrer"&gt;AI-200T00 course&lt;/a&gt; and the Microsoft Learn paths aligned to each domain are the primary resources once they publish alongside the beta exam. Are you finishing AZ-204 before July or pivoting to AI-200 now?&lt;/p&gt;

</description>
      <category>azure</category>
      <category>certification</category>
      <category>career</category>
      <category>learning</category>
    </item>
  </channel>
</rss>
