<?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.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>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>
    <item>
      <title>Running Azure Functions in Docker: Why and How</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 17 Apr 2026 05:23:51 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/running-azure-functions-in-docker-why-and-how-1hal</link>
      <guid>https://dev.to/martin_oehlert/running-azure-functions-in-docker-why-and-how-1hal</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;/blockquote&gt;

&lt;h2&gt;
  
  
  When zip-deploy stops fitting
&lt;/h2&gt;

&lt;p&gt;Your Azure Function needs to generate PDF invoices, so you add Puppeteer to your project. Zip-deploy works fine on your machine, but the Consumption plan doesn't have the Chromium dependencies installed. The function throws a cryptic error about missing shared libraries, and you're stuck choosing between a workaround that limits your architecture or a deployment model that gives you full control over the OS.&lt;/p&gt;

&lt;p&gt;Most Azure Functions never hit this wall. &lt;strong&gt;Zip-deploy&lt;/strong&gt; and &lt;strong&gt;run-from-package&lt;/strong&gt; handle the majority of workloads well: your code and dependencies get packaged, uploaded, and run on Microsoft's managed infrastructure. You don't think about the OS, the runtime image, or what's installed underneath. That's the point, and it's a good default.&lt;/p&gt;

&lt;p&gt;Containerizing a Function adds real operational cost. You own the base image, the patching cycle, the registry, and the build pipeline. If zip-deploy already works, containerizing your Function adds overhead with no payoff.&lt;/p&gt;

&lt;p&gt;But there are specific problems where Docker earns that overhead back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Native dependencies&lt;/strong&gt; are the most common trigger. FFmpeg for media processing, Puppeteer or Playwright for headless browser work, libgdiplus for image manipulation: these require OS-level packages that the default Azure Functions host doesn't include. A custom Docker image lets you install exactly what the function needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reproducible builds across environments&lt;/strong&gt; matter when your team needs the same OS, the same SDK version, and the same native tooling from local dev through staging to production. A Dockerfile pins all of it in version control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running Functions alongside other containers&lt;/strong&gt; is the third case. If you're already deploying to &lt;strong&gt;Azure Container Apps&lt;/strong&gt; or &lt;strong&gt;AKS&lt;/strong&gt;, packaging your Function as a container lets it sit next to your APIs, workers, and sidecars in the same orchestration layer. One deployment model, one scaling configuration, one set of infrastructure to manage.&lt;/p&gt;

&lt;p&gt;If one of those three problems is yours, the container tax is worth paying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dockerfile: multi-stage build for .NET 10
&lt;/h2&gt;

&lt;p&gt;Start with the complete Dockerfile, then walk through what each stage does.&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;--platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.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="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /src&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; *.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; /app/publish

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;--platform=linux/amd64 mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated10.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;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /home/site/wwwroot&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/publish .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fourteen lines. That's the whole thing.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--platform=linux/amd64&lt;/code&gt; flag on both &lt;code&gt;FROM&lt;/code&gt; lines pins the image architecture. The Azure Functions base images only ship for &lt;code&gt;linux/amd64&lt;/code&gt;, so without this flag, builds on Apple Silicon pull the wrong manifest and fail. Pinning the platform makes the Dockerfile work identically on Intel and ARM machines.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;build stage&lt;/strong&gt; uses the .NET 10 SDK image to compile your project. The &lt;code&gt;COPY *.csproj&lt;/code&gt; then &lt;code&gt;dotnet restore&lt;/code&gt; pattern caches NuGet packages in a Docker layer, so subsequent builds skip the restore unless your dependencies change. The &lt;code&gt;dotnet publish&lt;/code&gt; step compiles your code and produces a deployment-ready output in &lt;code&gt;/app/publish&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;runtime stage&lt;/strong&gt; switches to the Azure Functions base image. This image ships with the Functions host process, the dotnet-isolated worker runtime, and the three environment variables your app needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;AzureWebJobsScriptRoot=/home/site/wwwroot&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FUNCTIONS_WORKER_RUNTIME=dotnet-isolated&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AzureFunctionsJobHost__Logging__Console__IsEnabled=true&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don't need to set any of these yourself. The base image handles it. Your only job is to place the published output at &lt;code&gt;/home/site/wwwroot&lt;/code&gt;, which is why &lt;code&gt;WORKDIR&lt;/code&gt; must point there. Get that path wrong and the Functions host starts but finds zero functions.&lt;/p&gt;

&lt;p&gt;The final &lt;code&gt;COPY --from=build&lt;/code&gt; pulls the compiled output from the build stage into the runtime image, keeping the SDK and all intermediate build artifacts out of your production container.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you should know before building
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pin your SDK version in &lt;code&gt;global.json&lt;/code&gt;.&lt;/strong&gt; The base image &lt;code&gt;4-dotnet-isolated10.0&lt;/code&gt; bundles a specific .NET 10 runtime. If your local SDK rolls ahead of what the image ships, subtle mismatches at runtime can show up. Pinning keeps builds deterministic across laptops, CI, and the image:&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;"sdk"&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;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10.0.201"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rollForward"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"latestPatch"&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;Package version floor for .NET 10.&lt;/strong&gt; The isolated worker packages below 2.x don't target .NET 10. You need at minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Microsoft.Azure.Functions.Worker&lt;/code&gt; 2.50.0 or later&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Microsoft.Azure.Functions.Worker.Sdk&lt;/code&gt; 2.0.5 or later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're upgrading an existing project from .NET 8, bumping just the &lt;code&gt;TargetFramework&lt;/code&gt; without updating these two packages is the most common failure mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;.NET 10 doesn't run on the Linux Consumption plan.&lt;/strong&gt; This is a hard platform constraint, not a preview gap. If your current app runs on Linux Consumption and you want .NET 10, you need to &lt;a href="https://learn.microsoft.com/azure/azure-functions/migration/migrate-plan-consumption-to-flex" rel="noopener noreferrer"&gt;migrate to the Flex Consumption plan&lt;/a&gt; first. Premium, ACA, and AKS (covered later) all support .NET 10 without this restriction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Functions host runs on .NET 8 internally.&lt;/strong&gt; Even in the &lt;code&gt;dotnet-isolated10.0&lt;/code&gt; image, the host process itself targets .NET 8. Your worker process runs on .NET 10. This is expected behavior for the isolated model, not a bug: the two processes communicate over gRPC, so the runtime versions are independent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;These images are &lt;code&gt;linux/amd64&lt;/code&gt; only.&lt;/strong&gt; If you're on Apple Silicon, Docker Desktop runs them under Rosetta or QEMU emulation. Builds work fine. Performance is noticeably slower than native ARM execution, so keep local integration test suites short.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No slim variant exists for .NET 10 yet.&lt;/strong&gt; The base image is Ubuntu-based (the .NET 10 container images &lt;a href="https://learn.microsoft.com/dotnet/core/compatibility/containers/10.0/default-images-use-ubuntu" rel="noopener noreferrer"&gt;moved from Debian to Ubuntu&lt;/a&gt;), and the full image weighs roughly 1.5 GB. A Mariner-based or distroless option may come later, but as of April 2026, this is what ships.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You own base image updates.&lt;/strong&gt; Microsoft publishes monthly security patches to the base images, but unlike managed Functions deployments, custom containers do not auto-update. You pull the latest tag, rebuild, and redeploy. Set a calendar reminder or wire it into your CI pipeline. The &lt;a href="https://learn.microsoft.com/azure/azure-functions/container-concepts#maintaining-custom-containers" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; are explicit about this: maintaining your container is your responsibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local development with Docker Compose and Azurite
&lt;/h2&gt;

&lt;p&gt;Your function app needs storage. Timer triggers use it for lease management, queue triggers read from it directly, and durable functions store orchestration state there. In production that's an Azure Storage account. Locally, you need &lt;strong&gt;Azurite&lt;/strong&gt;, Microsoft's storage emulator, running alongside your function container.&lt;/p&gt;

&lt;p&gt;Here's the full Docker Compose 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="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;azurite&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcr.microsoft.com/azure-storage/azurite&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
      &lt;span class="s"&gt;azurite&lt;/span&gt;
      &lt;span class="s"&gt;--blobHost 0.0.0.0&lt;/span&gt;
      &lt;span class="s"&gt;--queueHost 0.0.0.0&lt;/span&gt;
      &lt;span class="s"&gt;--tableHost 0.0.0.0&lt;/span&gt;
      &lt;span class="s"&gt;--loose&lt;/span&gt;
      &lt;span class="s"&gt;--skipApiVersionCheck&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10000:10000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10001:10001"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10002:10002"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;azurite-data:/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nc -z 127.0.0.1 &lt;/span&gt;&lt;span class="m"&gt;10000&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;

  &lt;span class="na"&gt;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;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:80"&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=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;QueueEndpoint=http://azurite:10001/devstoreaccount1;TableEndpoint=http://azurite:10002/devstoreaccount1&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;azurite&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;azurite-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--blobHost 0.0.0.0&lt;/code&gt; flags (and their queue/table equivalents) tell Azurite to listen on all network interfaces, not just localhost. Without them, your function container can't reach Azurite across the Docker network. &lt;code&gt;--loose&lt;/code&gt; relaxes strict API validation. &lt;code&gt;--skipApiVersionCheck&lt;/code&gt; prevents version mismatch errors when the Functions runtime targets a newer Storage API than Azurite supports.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;named volume&lt;/strong&gt; &lt;code&gt;azurite-data&lt;/code&gt; keeps your storage data intact between &lt;code&gt;docker compose down&lt;/code&gt; and &lt;code&gt;docker compose up&lt;/code&gt;. Queue messages, blob uploads, table entities: all survive restarts. Drop the volume only when you want a clean slate (&lt;code&gt;docker compose down -v&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;health check&lt;/strong&gt; deserves attention. Without it, Docker starts both containers simultaneously. Your function app boots in seconds, tries to connect to Azurite, and fails because Azurite hasn't finished initializing. The &lt;code&gt;nc -z 127.0.0.1 10000&lt;/code&gt; check confirms Azurite is actually accepting connections before the function container starts.&lt;/p&gt;

&lt;p&gt;Now for the part that will cost you an hour if you don't know about it.&lt;/p&gt;

&lt;p&gt;Your first instinct for the storage connection string will be &lt;code&gt;UseDevelopmentStorage=true&lt;/code&gt;. That's what every Azure Functions tutorial uses, and it works fine when Azurite runs on your host machine. Inside Docker, it breaks. The shorthand expands to endpoints pointing at &lt;code&gt;127.0.0.1&lt;/code&gt;, which inside the function container means "myself," not "the Azurite container next door."&lt;/p&gt;

&lt;p&gt;The fix is the explicit connection string you see in the Compose file above. The critical difference: every endpoint URL uses &lt;code&gt;azurite&lt;/code&gt; as the hostname (the Compose service name) instead of &lt;code&gt;127.0.0.1&lt;/code&gt;. Docker's internal DNS resolves &lt;code&gt;azurite&lt;/code&gt; to the correct container IP automatically. The account name and key are Azurite's well-known development credentials, the same ones &lt;code&gt;UseDevelopmentStorage=true&lt;/code&gt; uses under the hood.&lt;/p&gt;

&lt;p&gt;One practical tip: that connection string is long and ugly. Don't try to split it across multiple lines in your Compose file or inject it from a &lt;code&gt;.env&lt;/code&gt; file with line breaks. YAML will quietly mangle it. Keep it on a single line, or use a &lt;code&gt;.env&lt;/code&gt; file with the entire value on one line and reference it with &lt;code&gt;${AzureWebJobsStorage}&lt;/code&gt; in your Compose file.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;docker compose up --build&lt;/code&gt; and you should see Azurite report all three services listening, followed by your function app discovering its triggers. If the function container restarts in a loop, check the connection string first. Nine times out of ten, that's the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging in containers: VS Code and Rider
&lt;/h2&gt;

&lt;p&gt;Add a debug stage to your Dockerfile that installs the .NET debugger:&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;build&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;debug&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;dotnet tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--tool-path&lt;/span&gt; /tools dotnet-dump
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    curl unzip procps &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://aka.ms/getvsdbgsh | bash /dev/stdin &lt;span class="nt"&gt;-v&lt;/span&gt; latest &lt;span class="nt"&gt;-l&lt;/span&gt; /vsdbg &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get clean &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; DOTNET_USE_POLLING_FILE_WATCHER=1&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["dotnet", "run", "--project", "/src/HttpTriggerDemo"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;DOTNET_USE_POLLING_FILE_WATCHER&lt;/code&gt; environment variable is required because Docker volume mounts don't support &lt;code&gt;inotify&lt;/code&gt;. Without it, file change detection silently fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  VS Code with pipeTransport
&lt;/h3&gt;

&lt;p&gt;Point your &lt;code&gt;launch.json&lt;/code&gt; at the container using &lt;strong&gt;pipeTransport&lt;/strong&gt; instead of opening a debug port:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Attach to Docker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"coreclr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"attach"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"processId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${command:pickRemoteProcess}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pipeTransport"&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;"pipeProgram"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"docker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pipeArgs"&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="s2"&gt;"exec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-i"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-functions-debug"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"debuggerPath"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/vsdbg/vsdbg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pipeCwd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${workspaceFolder}"&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;"sourceFileMap"&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;"/src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${workspaceFolder}"&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;code&gt;pipeTransport&lt;/code&gt; sends debug commands through &lt;code&gt;docker exec&lt;/code&gt;, so you never expose a debug port. The &lt;code&gt;sourceFileMap&lt;/code&gt; entry maps the container's &lt;code&gt;/src&lt;/code&gt; path back to your workspace so breakpoints resolve correctly. Start the container, hit F5 in VS Code, pick the &lt;code&gt;dotnet&lt;/code&gt; process, and you're attached.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rider
&lt;/h3&gt;

&lt;p&gt;Rider handles most of this automatically. Open &lt;strong&gt;Run &amp;gt; Attach to Process&lt;/strong&gt;, select the &lt;strong&gt;Docker&lt;/strong&gt; tab, and pick your container. Rider installs its own debug agent on first attach. If you use Docker Compose, Rider also supports a native &lt;strong&gt;Docker Compose&lt;/strong&gt; run configuration that builds, starts, and attaches in one step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker Compose debug profile
&lt;/h3&gt;

&lt;p&gt;Separate your debug configuration using a &lt;strong&gt;Compose profile&lt;/strong&gt; so it doesn't interfere with production builds:&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-debug&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="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;debug&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./src:/src&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;DOTNET_USE_POLLING_FILE_WATCHER=1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;AzureWebJobsStorage=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;QueueEndpoint=http://azurite:10001/devstoreaccount1;TableEndpoint=http://azurite:10002/devstoreaccount1&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;azurite&lt;/span&gt;
    &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;debug&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;docker compose --profile debug up&lt;/code&gt;. The &lt;code&gt;target: debug&lt;/code&gt; directive tells Compose to stop at your debug stage, which includes the SDK and &lt;code&gt;vsdbg&lt;/code&gt; but skips the production publish step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hot reload: set expectations
&lt;/h3&gt;

&lt;p&gt;Using &lt;code&gt;dotnet watch&lt;/code&gt; to wrap &lt;code&gt;func start&lt;/code&gt; inside the container works, but every code change triggers a full restart. Expect 4-6 second cycles. That's usable for occasional debugging sessions, not for rapid iteration.&lt;/p&gt;

&lt;p&gt;The pragmatic split: run &lt;code&gt;func start&lt;/code&gt; on your host machine for day-to-day development. Keep Azurite and any dependencies (Service Bus emulator, CosmosDB emulator) in Docker. Reserve full-container debugging for integration testing or reproducing environment-specific issues. You get fast inner-loop feedback without giving up the production-parity benefits of containerized dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to deploy: ACA vs Premium vs AKS
&lt;/h2&gt;

&lt;p&gt;You have a containerized Function. Now you need somewhere to run it. Three options exist, and each makes a different trade-off between operational control and managed convenience.&lt;/p&gt;

&lt;h3&gt;
  
  
  Azure Container Apps (ACA)
&lt;/h3&gt;

&lt;p&gt;ACA is the recommended default for containerized Azure Functions. The platform reads your Function triggers and configures KEDA scaling rules automatically, so you never write ScaledObject YAML yourself.&lt;/p&gt;

&lt;p&gt;Deploy with the Azure CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az containerapp create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; my-functions &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;--environment&lt;/span&gt; my-env &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; myregistry.azurecr.io/my-functions:latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--registry-server&lt;/span&gt; myregistry.azurecr.io &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ingress&lt;/span&gt; external &lt;span class="nt"&gt;--target-port&lt;/span&gt; 80 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--min-replicas&lt;/span&gt; 0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-replicas&lt;/span&gt; 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;--min-replicas 0&lt;/code&gt; and your app scales to zero when idle, meaning zero compute cost during quiet periods.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing&lt;/strong&gt; follows the Container Apps model. On the Consumption plan, you pay per vCPU-second and GiB-second, with a monthly free grant of 180,000 vCPU-seconds and 360,000 GiB-seconds per subscription. For a Function that processes a few thousand events per day and idles overnight, you could land under $5/month. Dedicated workload profiles are available if you need guaranteed compute or GPU access, billed per instance rather than per resource consumed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold start&lt;/strong&gt; is the main gotcha. When your app scales from zero, the platform needs to pull the container image, provision resources, and start the Functions host. For a typical .NET isolated Function, teams commonly report 5-15 seconds on the first request after an idle period (Microsoft doesn't publish official cold start numbers). You can eliminate this by setting &lt;code&gt;--min-replicas 1&lt;/code&gt;, but that means you pay for at least one instance around the clock. Keeping your container image small (pin to a specific tag, avoid unnecessary layers) helps reduce cold start time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What ACA does not support:&lt;/strong&gt; deployment slots, Functions access keys via the portal, and Functions proxies. If you rely on staging slots for zero-downtime swaps, you'll need to use ACA's built-in blue-green deployment with traffic splitting instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Azure Functions Premium Plan
&lt;/h3&gt;

&lt;p&gt;The Premium plan (Elastic Premium, SKUs starting with &lt;code&gt;EP&lt;/code&gt;) is the original way to run custom containers in Azure Functions. It predates ACA and still has one killer feature: &lt;strong&gt;always-ready instances with prewarmed buffers&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az functionapp plan create &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-premium-plan &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; eastus &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--sku&lt;/span&gt; EP1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--is-linux&lt;/span&gt;

az functionapp create &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;--plan&lt;/span&gt; my-premium-plan &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; my-functions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--deployment-container-image-name&lt;/span&gt; myregistry.azurecr.io/my-functions:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three SKU sizes are available:&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%2Fr6vvifjh9f1b528k9pu5.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%2Fr6vvifjh9f1b528k9pu5.png" alt="Premium plan SKU sizes: EP1 (1 vCPU, 3.5 GB), EP2 (2 vCPUs, 7 GB), EP3 (4 vCPUs, 14 GB)" width="346" height="174"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The billing model is the critical difference from ACA.&lt;/strong&gt; Premium plan charges per core-second and memory across all allocated instances, with no execution charge. At least one instance must always be running. An EP1 instance running 24/7 costs roughly $155-175/month (varies by region). You cannot scale to zero. That always-on instance is the price you pay for eliminating cold starts entirely.&lt;/p&gt;

&lt;p&gt;Where the Premium plan shines is latency-sensitive HTTP traffic. When load spikes, prewarmed instances are already initialized and waiting. No container pull, no cold start. For Functions that must respond in under 200ms consistently, this matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for the SKU naming confusion.&lt;/strong&gt; &lt;code&gt;EP1&lt;/code&gt; is Elastic Premium (dynamic scaling). &lt;code&gt;P1V2&lt;/code&gt; is a Dedicated App Service plan (no dynamic scaling). Pick the wrong one and you'll pay more for less flexibility.&lt;/p&gt;

&lt;p&gt;Maximum scale-out is up to 100 instances. The default &lt;code&gt;maximumElasticWorkerCount&lt;/code&gt; in ARM templates is 20, so you may need to raise that limit explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  AKS with KEDA
&lt;/h3&gt;

&lt;p&gt;If your team already operates a Kubernetes cluster, running Functions there avoids introducing a new compute platform. You install KEDA as an AKS add-on, deploy your Function container as a standard Kubernetes deployment, and KEDA handles scaling based on event triggers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keda.sh/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ScaledObject&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-functions-scaler&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scaleTargetRef&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;my-functions&lt;/span&gt;
  &lt;span class="na"&gt;minReplicaCount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
  &lt;span class="na"&gt;maxReplicaCount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azure-servicebus&lt;/span&gt;
      &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;queueName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders&lt;/span&gt;
        &lt;span class="na"&gt;messageCount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5"&lt;/span&gt;
      &lt;span class="na"&gt;authenticationRef&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;servicebus-auth&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;KEDA supports these Azure Functions triggers directly: Azure Storage Queues, Azure Service Bus, Azure Event Hubs / IoT Hubs, Apache Kafka, and RabbitMQ. HTTP triggers work, but KEDA does not manage them directly; you configure HTTP scaling through the Horizontal Pod Autoscaler or Container Apps' HTTP scaler instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is the only option that is community-supported, not Microsoft-supported.&lt;/strong&gt; The docs are explicit: "Best-effort support is provided by contributors and from the community." If something breaks at 2am, you're opening a GitHub issue, not filing a support ticket. You also own the full Kubernetes stack: node pools, networking, RBAC, upgrades, monitoring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost&lt;/strong&gt; depends entirely on your cluster. If you're already paying for AKS nodes, adding a Function container is effectively free at the compute layer. If you'd be spinning up a new cluster just for Functions, the minimum AKS cost (one node with a Standard_D2s_v3 VM) starts around $70/month before you've deployed anything. KEDA itself is free and runs as a lightweight deployment in your cluster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold start&lt;/strong&gt; on AKS matches whatever your cluster can provision. With KEDA's scale-to-zero, a cold start involves scheduling a pod, pulling the image (if not cached), and starting the container. On a warm cluster with cached images, that's 3-10 seconds. On a cluster that needs to scale up a node, it could be 2-4 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trade-offs at a glance
&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%2Fap8kfyjh0ly2s5f8v0su.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%2Fap8kfyjh0ly2s5f8v0su.png" alt="Trade-offs comparison: ACA vs Premium Plan vs AKS with KEDA" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The decision tree is short.&lt;/strong&gt; If you don't already run Kubernetes, don't start now for a single Function app. If your Function handles latency-sensitive HTTP requests and cold starts are unacceptable, use the Premium plan and accept the always-on cost. For everything else, ACA with the Consumption plan gives you scale-to-zero, automatic KEDA configuration, and the lowest operational overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Docker adds value
&lt;/h2&gt;

&lt;p&gt;The deployment choice assumes the container was worth building in the first place. Every custom container you ship is infrastructure you now own: a registry to manage, a base image to patch monthly, a CI pipeline stage that didn't exist before. Zip-deploy skips all of that. Microsoft patches the managed host, and you never think about it.&lt;/p&gt;

&lt;p&gt;That trade-off only flips when the managed host can't do what your function requires. Puppeteer needs Chromium installed at the OS level. Your compliance team mandates identical images from laptop to production. Your platform team already runs everything on AKS and adding a second deployment model would create more problems than it solves. Those are real constraints, not preferences.&lt;/p&gt;

&lt;p&gt;The setup cost is lower than it looks. Twelve lines of Dockerfile, a Compose file with Azurite, and one container image that deploys to ACA, Premium, or AKS without changes. The ongoing cost is the part that matters: monthly base image pulls, rebuild-and-redeploy cycles, and one more thing to monitor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is your function broken without OS-level control, or would zip-deploy work fine if you tried it first?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>azure</category>
      <category>dotnet</category>
      <category>docker</category>
      <category>azurefunctions</category>
    </item>
    <item>
      <title>Production Realities: When Azure Functions Stops Being Serverless</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 10 Apr 2026 05:38:43 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/production-realities-when-azure-functions-stops-being-serverless-p2g</link>
      <guid>https://dev.to/martin_oehlert/production-realities-when-azure-functions-stops-being-serverless-p2g</guid>
      <description>&lt;h2&gt;
  
  
  The Enterprise Reality Check
&lt;/h2&gt;

&lt;p&gt;At what point does an Azure Functions deployment stop being serverless and start being managed compute with a monthly bill? The shift happens not in one decision but in a sequence of reasonable ones: a VNet requirement from the security team, then private endpoints for the storage account, then an API gateway because the function is public-facing.&lt;/p&gt;

&lt;p&gt;Your function works on Consumption. Zero cost at idle, automatic scaling, no infrastructure to think about. Then the security review lands. VNet integration is mandatory. Consumption doesn't support VNets, so you move to Flex Consumption. Private endpoints? Flex handles those too. You're still paying close to nothing at idle.&lt;/p&gt;

&lt;p&gt;Then the surrounding infrastructure arrives. API Management adds $147 to $700. WAF protection adds $333. Each requirement passes its own cost-benefit test. None of them would make you question the architecture on their own. But the total floor lands somewhere between $530 and $1,080 per month, and the function plan itself is the smallest line item on the invoice.&lt;/p&gt;

&lt;p&gt;The serverless pitch from Part 1 of this series was real. It just applies to a narrower set of workloads than most teams expect when they start. Once you're past that boundary, the question isn't whether to pay more. It's whether the Functions abstraction is still worth paying for, or whether Container Apps or App Service would give you the same outcome with less friction.&lt;/p&gt;

&lt;h2&gt;
  
  
  VNet: The Requirement That Changes Everything
&lt;/h2&gt;

&lt;p&gt;Most enterprise environments mandate &lt;strong&gt;VNet integration&lt;/strong&gt; for anything touching internal databases, key vaults behind private endpoints, or services that shouldn't be exposed to the public internet. The Consumption plan doesn't support VNet. That single requirement forces you into a different hosting plan, and each plan carries different pricing and operational constraints.&lt;/p&gt;

&lt;p&gt;These are your options (East US pricing, April 2026):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;VNet&lt;/th&gt;
&lt;th&gt;Scale to Zero&lt;/th&gt;
&lt;th&gt;Min Idle Cost/mo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Consumption&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flex Consumption (on-demand)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flex Consumption (1 always-ready, 2 GB)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;~$21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premium EP1&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;~$146&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premium EP2&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;~$291&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dedicated S1/P1v3&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;varies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container Apps (1 replica, 0.25 vCPU)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~$10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The jump from Consumption to Premium EP1 is $146/month for a single function app sitting idle. That's the cost of VNet access before your code processes a single request. Premium EP2 doubles it. These aren't theoretical numbers: they're the minimum monthly charges while your function waits for traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flex Consumption&lt;/strong&gt; went GA in November 2024, and Microsoft now positions it as the recommended path for apps that need dynamic scaling with VNet support. In on-demand mode, Flex preserves the scale-to-zero model that made Consumption attractive. It also skips the Azure Files dependency (the shared file system Premium uses for deployment artifacts and runtime state). If your security team mandates private networking for storage, that saves roughly $30/month on private endpoint costs you'd otherwise pay on Premium. Under the hood, Flex uses shared gateways (up to 27 shared gateway IPs) instead of dedicated VNet-injected workers. That's how it keeps costs lower.&lt;/p&gt;

&lt;p&gt;If you need guaranteed warm instances to avoid cold starts, Flex's always-ready configuration starts at about $21/month for one instance with 2 GB memory. That's still a fraction of Premium EP1.&lt;/p&gt;

&lt;p&gt;But Flex has real constraints. Before you commit to it, compare what you're giving up:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constraint&lt;/th&gt;
&lt;th&gt;Flex Consumption&lt;/th&gt;
&lt;th&gt;Premium&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;Linux only&lt;/td&gt;
&lt;td&gt;Windows + Linux&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apps per plan&lt;/td&gt;
&lt;td&gt;One&lt;/td&gt;
&lt;td&gt;Multiple&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment slots&lt;/td&gt;
&lt;td&gt;Not supported&lt;/td&gt;
&lt;td&gt;Up to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-process .NET&lt;/td&gt;
&lt;td&gt;Not supported&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App init timeout&lt;/td&gt;
&lt;td&gt;Fixed 30s&lt;/td&gt;
&lt;td&gt;No limit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NFS file shares&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regional availability&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Broad&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;strong&gt;one-app-per-plan&lt;/strong&gt; limitation is easy to overlook. You can't consolidate multiple function apps onto a single Flex plan the way you would with Premium. For teams running five or ten function apps, Premium's ability to share a single plan across all of them can actually cost less per app than running each on its own Flex instance.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;30-second app init timeout&lt;/strong&gt; is fixed. Not configurable. If your function app loads large dependency injection containers and connects to multiple databases at startup, 30 seconds may not be enough. Premium has no startup timeout limit, so heavy initialization is never a problem there.&lt;/p&gt;

&lt;p&gt;If your codebase uses &lt;strong&gt;in-process .NET&lt;/strong&gt; (the older hosting model where your function runs inside the Functions host process), Flex doesn't support it. You'd need to migrate to the isolated worker model first, which is its own project.&lt;/p&gt;

&lt;p&gt;If you need Windows, deployment slots, or in-process .NET: Premium is your only option. If you're on Linux with the isolated worker model and can live with one app per plan, Flex Consumption gives you VNet support without abandoning scale-to-zero.&lt;/p&gt;

&lt;p&gt;One more thing worth knowing: Linux Consumption is on a deprecation path. No new features after September 2025, with retirement scheduled for September 2028. Microsoft is pushing new workloads toward Flex Consumption, and the deprecation timeline makes that push harder to ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Plan Escalation Path
&lt;/h2&gt;

&lt;p&gt;You start on Consumption. Your function triggers on an HTTP request, processes a message, writes to Cosmos DB. It costs nothing when idle. The serverless model, doing what it's supposed to do.&lt;/p&gt;

&lt;p&gt;Then the requirements start arriving, one at a time.&lt;/p&gt;

&lt;h3&gt;
  
  
  VNet integration
&lt;/h3&gt;

&lt;p&gt;Your security team requires all compute to run inside a virtual network. Consumption doesn't support VNet integration, so you move to &lt;strong&gt;Flex Consumption&lt;/strong&gt; (on-demand only). This still scales to zero. You're still paying nothing at idle. No problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running total: $0/mo idle&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Private endpoints
&lt;/h3&gt;

&lt;p&gt;Next review: inbound traffic to your function app must go through a private endpoint, and the backing storage accounts need private endpoints too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flex Consumption supports inbound private endpoints.&lt;/strong&gt; You don't need to leave Flex for this. Your function app keeps scale-to-zero, and the private endpoint adds ~$7/month.&lt;/p&gt;

&lt;p&gt;Flex also needs private endpoints for its backing storage accounts: Blob, Queue, and Table. Three endpoints, not four, because Flex has no Azure Files dependency. That's &lt;strong&gt;~$22/mo&lt;/strong&gt; for storage endpoints.&lt;/p&gt;

&lt;p&gt;One deployment gotcha worth knowing: combining VNet integration with inbound private endpoints on Flex can cause deployment timeouts at the Kudu RemoveWorkersStep. The current workaround is temporarily removing the private endpoint during deployments, then re-adding it. Not ideal for automated pipelines, and worth factoring into your CI/CD design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running total: ~$29/mo (Flex on-demand)&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The fork: when Premium becomes unavoidable
&lt;/h3&gt;

&lt;p&gt;Most teams can stay on Flex through the VNet and private endpoint requirements. But Flex has constraints that force some teams onto &lt;strong&gt;Premium EP1&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows hosting&lt;/strong&gt; required&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment slots&lt;/strong&gt; for blue-green deployments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-process .NET&lt;/strong&gt; (not yet migrated to isolated worker)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple function apps&lt;/strong&gt; sharing a single plan&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App init exceeding 30 seconds&lt;/strong&gt; (Flex's hard timeout)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of these apply, EP1 gives you 1 vCPU and 3.5 GB of memory. The math: 1 vCPU at $116.80 plus 3.5 GB at $8.322 per GB = &lt;strong&gt;~$146/mo&lt;/strong&gt;. It runs 24/7 whether your function executes or not. Storage private endpoints on Premium cost &lt;strong&gt;~$30/mo&lt;/strong&gt; (four endpoints, including Azure Files).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running total if forced to Premium: ~$176/mo&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Cold starts
&lt;/h3&gt;

&lt;p&gt;On Flex, cold starts are still possible when scaling from zero. If your workload needs guaranteed warm instances, Flex's always-ready configuration starts at ~$21/month for one instance with 2 GB memory. On Premium, cold starts are a non-issue: EP1 keeps at least one instance warm by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running total: ~$50/mo (Flex + always-ready) or ~$176/mo (Premium)&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  API Management
&lt;/h3&gt;

&lt;p&gt;Your API needs rate limiting and a developer portal. You add &lt;strong&gt;Azure API Management&lt;/strong&gt;. The pricing depends on what your organization needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;APIM Basic (classic)&lt;/strong&gt;: ~$147/mo, no VNet integration, 99.95% SLA&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;APIM Standard v2&lt;/strong&gt;: ~$700/mo, partial VNet support (backend only), 99.95% SLA&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;APIM Premium (classic)&lt;/strong&gt;: ~$2,795/mo, full VNet integration, 99.99% SLA&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most teams start with Basic and accept the VNet gap. Some compliance requirements force Standard v2 or higher.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running total: ~$197/mo (Flex + Basic) or ~$323/mo (Premium + Basic)&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  WAF protection
&lt;/h3&gt;

&lt;p&gt;Compliance also wants a Web Application Firewall in front of your API. You deploy &lt;strong&gt;Application Gateway WAF_v2&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The breakdown: $0.443/hour for 730 hours ($323), plus at least one capacity unit ($10.50), plus a public IP ($3.65). That's &lt;strong&gt;~$333-335/mo&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Application Gateway v1 retires April 28, 2026, so WAF_v2 is the only option going forward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running total: ~$530/mo (Flex floor) or ~$656/mo (Premium floor)&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The full picture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Consumption ($0 idle)
  + VNet requirement
  → Flex on-demand: still $0 idle

  + private endpoints (inbound + storage)
  → Flex: ~$29/mo (1 inbound PE + 3 storage PEs)
  → Premium (if forced by constraints): ~$176/mo (EP1 + 4 storage PEs)

  + cold start elimination
  → Flex always-ready: ~$21/mo
  → Premium: included (always-on)

  + API Management
  → APIM Basic: ~$147/mo
  → OR APIM Standard v2: ~$700/mo

  + WAF protection
  → Application Gateway WAF_v2: ~$333/mo

  Flex path:
  = ~$530/mo floor (Flex + always-ready + PEs + APIM Basic + WAF)
  = ~$1,083/mo ceiling (Flex + PEs + APIM Standard v2 + WAF)

  Premium path (forced by constraints):
  = ~$656/mo floor (EP1 + PEs + APIM Basic + WAF)
  = ~$1,209/mo ceiling (EP1 + PEs + APIM Standard v2 + WAF)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of these requirements is reasonable on its own. Your security team isn't wrong to ask for VNet integration. Private endpoints are a real protection. APIM and WAF exist because APIs need them.&lt;/p&gt;

&lt;p&gt;The function plan itself is the smallest factor. On Flex, your compute cost at idle is $0 to $50/month. On Premium, it's $146 to $176. Either way, APIM and WAF add $480 to $1,033 on top. Those two services dominate the bill regardless of which Functions plan you choose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build and Deploy Friction
&lt;/h2&gt;

&lt;p&gt;The cost story from the plan escalation section is the monthly bill. The deployment story is the engineering time you spend before your code even runs.&lt;/p&gt;

&lt;p&gt;You cannot convert a function app from one plan to another in place. Moving from Consumption to Flex Consumption, or from Premium to Flex, means creating a new function app, redeploying your code, and deleting the old one. There is no &lt;code&gt;az functionapp update --sku FC1&lt;/code&gt;. Microsoft's own migration guide recommends running both apps in parallel during a transition period, then cutting over. For production workloads, that's a blue-green deployment you didn't plan for.&lt;/p&gt;

&lt;h3&gt;
  
  
  The app settings cleanup
&lt;/h3&gt;

&lt;p&gt;Flex Consumption deprecates roughly 20 app settings and site properties that other plans rely on. If you copy your existing configuration to a new Flex app without cleaning it up, the deployment fails or the app behaves unpredictably.&lt;/p&gt;

&lt;p&gt;These settings must be removed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Deployment (handled by functionAppConfig.deployment.storage on Flex)
WEBSITE_RUN_FROM_PACKAGE

# Azure Files (Flex has no Azure Files dependency)
WEBSITE_CONTENTAZUREFILECONNECTIONSTRING
WEBSITE_CONTENTSHARE
WEBSITE_SKIP_CONTENTSHARE_VALIDATION

# Networking (inherited from the integrated VNet on Flex)
WEBSITE_CONTENTOVERVNET
WEBSITE_VNET_ROUTE_ALL
WEBSITE_DNS_SERVER

# Runtime (managed via functionAppConfig.runtime on Flex)
FUNCTIONS_EXTENSION_VERSION
FUNCTIONS_WORKER_RUNTIME

# Scaling (renamed in functionAppConfig.scaleAndConcurrency)
WEBSITE_MAX_DYNAMIC_APPLICATION_SCALE_OUT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most dangerous one is &lt;code&gt;WEBSITE_RUN_FROM_PACKAGE&lt;/code&gt;. On Consumption and Premium, this setting controls how your code gets deployed. On Flex, it must not exist. Flex uses &lt;code&gt;functionAppConfig.deployment.storage&lt;/code&gt; to point at a blob container instead of Azure Files. If &lt;code&gt;WEBSITE_RUN_FROM_PACKAGE&lt;/code&gt; is still present, the deployment silently uses the wrong mechanism.&lt;/p&gt;

&lt;p&gt;Site properties change too. &lt;code&gt;alwaysOn&lt;/code&gt; must be &lt;code&gt;false&lt;/code&gt; on Flex (it's invalid), but &lt;code&gt;true&lt;/code&gt; on Premium and Dedicated. &lt;code&gt;functionsRuntimeScaleMonitoringEnabled&lt;/code&gt; is unnecessary on Flex because scale monitoring is built in, but forgetting to remove it won't break anything. ARM template properties like &lt;code&gt;linuxFxVersion&lt;/code&gt;, &lt;code&gt;containerSize&lt;/code&gt;, and &lt;code&gt;isReserved&lt;/code&gt; are all replaced by the &lt;code&gt;functionAppConfig&lt;/code&gt; section.&lt;/p&gt;

&lt;h3&gt;
  
  
  Infrastructure as Code breaks across plans
&lt;/h3&gt;

&lt;p&gt;Your Terraform and Bicep templates don't just need new property values. They need different resources entirely.&lt;/p&gt;

&lt;p&gt;In Terraform, &lt;code&gt;azurerm_linux_function_app&lt;/code&gt; does not work with the Flex Consumption SKU. Attempting to provision it with an &lt;code&gt;FC1&lt;/code&gt; service plan fails. You need &lt;code&gt;azurerm_function_app_flex_consumption&lt;/code&gt;, a separate resource introduced in AzureRM provider v4.21.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Consumption / Premium: this resource&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_linux_function_app"&lt;/span&gt; &lt;span class="s2"&gt;"func"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;service_plan_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_service_plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Flex Consumption: different resource, different schema&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_function_app_flex_consumption"&lt;/span&gt; &lt;span class="s2"&gt;"func"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;service_plan_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_service_plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;site_config&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nx"&gt;storage_container_type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"blobContainer"&lt;/span&gt;
  &lt;span class="nx"&gt;storage_container_endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${azurerm_storage_account.sa.primary_blob_endpoint}${azurerm_storage_container.deploy.name}"&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 Flex resource requires a blob storage container for deployments (no Azure Files), supports &lt;code&gt;maximum_instance_count&lt;/code&gt; and &lt;code&gt;instance_memory_mb&lt;/code&gt; properties that don't exist on the standard resource, and has its own quirks. As of early 2026, you still need to set &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; to an empty string as a workaround when using managed identity authentication, then use &lt;code&gt;AzureWebJobsStorage__accountName&lt;/code&gt; for the actual connection.&lt;/p&gt;

&lt;p&gt;In Bicep, the SKU values map to different tiers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;SKU Name&lt;/th&gt;
&lt;th&gt;SKU Tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Consumption&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Y1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Dynamic&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flex Consumption&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FC1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FlexConsumption&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premium&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EP1&lt;/code&gt;/&lt;code&gt;EP2&lt;/code&gt;/&lt;code&gt;EP3&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ElasticPremium&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;FC1&lt;/code&gt; plan also requires &lt;code&gt;reserved: true&lt;/code&gt; and a &lt;code&gt;functionAppConfig&lt;/code&gt; section that replaces most of the properties you'd normally set as app settings. That's a structural rewrite of your deployment template, not a property change.&lt;/p&gt;

&lt;h3&gt;
  
  
  CI/CD pipeline adjustments
&lt;/h3&gt;

&lt;p&gt;GitHub Actions requires the &lt;code&gt;sku&lt;/code&gt; parameter in &lt;code&gt;azure/functions-action&lt;/code&gt; when deploying to Flex Consumption with a publish profile:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Azure/functions-action@v1&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;app-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.FUNCTION_APP_NAME }}&lt;/span&gt;
    &lt;span class="na"&gt;package&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.PACKAGE_PATH }}&lt;/span&gt;
    &lt;span class="na"&gt;publish-profile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PUBLISH_PROFILE }}&lt;/span&gt;
    &lt;span class="na"&gt;sku&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;flexconsumption'&lt;/span&gt;
    &lt;span class="na"&gt;remote-build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;sku: 'flexconsumption'&lt;/code&gt;, the action deploys using the standard Consumption mechanism, which fails silently or produces a broken deployment. With OIDC or service principal authentication, the action can auto-detect the SKU, but publish profile deployments need it explicitly.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;scm-do-build-during-deployment&lt;/code&gt; and &lt;code&gt;enable-oryx-build&lt;/code&gt; flags that you might have in your existing workflow are also wrong for Flex. Flex always performs an Oryx build during remote deployment. Setting those flags manually can interfere with the process.&lt;/p&gt;

&lt;h3&gt;
  
  
  Private endpoints break GitHub-hosted runners
&lt;/h3&gt;

&lt;p&gt;If your function app runs on Premium with private endpoints enabled, the SCM/Kudu site is not publicly reachable. GitHub-hosted runners cannot connect to it. Your deployment fails with &lt;code&gt;Failed to fetch Kudu App Settings (CODE: 404)&lt;/code&gt; or a 401, and the error message gives you almost no indication that networking is the problem.&lt;/p&gt;

&lt;p&gt;Your options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted runner inside the VNet&lt;/strong&gt;: works, but now you're maintaining a VM ($50-100/month) to deploy a "serverless" function&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub-hosted runners with Azure private networking&lt;/strong&gt;: GitHub can inject a runner NIC directly into your VNet subnet, giving hosted runners private access without self-hosted infrastructure. Requires a GitHub Team or Enterprise Cloud plan and larger runners (2-64 vCPU, per-minute billing). Supported in 25 Azure regions as of early 2026, but notably not West Europe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy via ARM using a service principal&lt;/strong&gt;: bypasses SCM entirely, pushes configuration through the Azure Resource Manager API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage to blob storage&lt;/strong&gt;: upload your package to a storage account the function app can reach, then trigger deployment from there&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Premium, you also need &lt;code&gt;WEBSITE_SKIP_CONTENTSHARE_VALIDATION=1&lt;/code&gt; in your ARM or Bicep templates when the backing storage account has a firewall or private endpoints. Without it, the ARM deployment fails during content share validation because the deployment engine can't reach the storage account through the private endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  The compound effect
&lt;/h3&gt;

&lt;p&gt;Any one of these issues is a half-day fix. The compound effect is what costs real engineering time: you change plans, which changes your Terraform resources, which changes your app settings, which changes your GitHub Actions workflow, which breaks because of private endpoint networking. Each layer has its own failure mode, its own error messages, and its own documentation scattered across different Microsoft Learn pages.&lt;/p&gt;

&lt;p&gt;The real cost of plan migration shows up in the sprint consumed by infrastructure work, not in the Azure bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Serverless Stops Making Sense
&lt;/h2&gt;

&lt;p&gt;At some point, the friction outweighs the abstraction. If you're paying $530/month or more, fighting plan migrations in Terraform, and managing deployment workarounds because private endpoints interfere with your CI pipeline, you should be asking: is the Functions hosting model still earning its keep?&lt;/p&gt;

&lt;p&gt;Signs it's time to look elsewhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your total infrastructure cost exceeds what the "serverless" label saves you in operational effort&lt;/li&gt;
&lt;li&gt;You're spending more time working around platform constraints than building features&lt;/li&gt;
&lt;li&gt;Your build and deploy pipeline is already as complex as it would be with containers&lt;/li&gt;
&lt;li&gt;Your team needs operational control (sidecars, traffic splitting, custom health probes) that Functions doesn't expose&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two alternatives worth evaluating are Azure Container Apps and App Service. They solve different problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  Azure Container Apps: the container-native path
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Azure Container Apps&lt;/strong&gt; (ACA) can host the Functions runtime directly. The v2 model, which Microsoft recommends for all new deployments, creates a single &lt;code&gt;Microsoft.App&lt;/code&gt; resource with &lt;code&gt;kind=functionapp&lt;/code&gt;. No hidden proxy resources, no dual-resource management. Your function app is a container app with Functions triggers and bindings wired in.&lt;/p&gt;

&lt;p&gt;The resource definition looks like a normal container app deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az containerapp create &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;--resource-group&lt;/span&gt; rg-prod &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--environment&lt;/span&gt; my-aca-env &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; myregistry.azurecr.io/my-func:latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kind&lt;/span&gt; functionapp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--min-replicas&lt;/span&gt; 0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-replicas&lt;/span&gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;KEDA&lt;/strong&gt; (Kubernetes-based Event Driven Autoscaling) handles scaling. The Functions runtime automatically configures KEDA scale rules based on your triggers. You don't write KEDA definitions yourself; the platform infers them from your bindings. HTTP, Service Bus, Event Hubs, Queue Storage, and other triggers all map to KEDA scalers behind the scenes, and your app can scale to zero when idle.&lt;/p&gt;

&lt;p&gt;What you gain over Premium Functions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: a single replica at 0.25 vCPU / 512 MB idles at roughly ~$10/month on the Consumption workload profile. Compare that to Premium EP1 at ~$146/month. With scale-to-zero, idle cost drops to $0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sidecar containers&lt;/strong&gt;: run log forwarders, auth proxies, or Dapr sidecars alongside your function app in the same pod&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dapr integration&lt;/strong&gt;: pub/sub, state management, and service invocation without managing the infrastructure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traffic splitting via revisions&lt;/strong&gt;: route a percentage of traffic to a new version before promoting it, something Functions deployment slots can't do with the same granularity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU support&lt;/strong&gt;: if you're running inference workloads alongside event-driven functions, ACA supports GPU-backed workload profiles&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you give up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Containerization is mandatory&lt;/strong&gt;. There is no code-only deployment path. You build a Docker image, push it to a registry, and deploy from there. If your team has no container experience, this is a real adoption cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No built-in continuous deployment&lt;/strong&gt; from the Functions tooling. You wire up GitHub Actions or Azure Pipelines yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inbound Private Endpoints through the Functions networking layer are not available.&lt;/strong&gt; The Functions networking features table on Microsoft's docs shows a blank cell for "Inbound Private Endpoints" under the Container Apps column. ACA itself supports private endpoints at the environment level (workload profiles environments only), but the Functions-specific private endpoint feature does not carry over. If your compliance requirements specifically mandate Functions inbound private endpoints, Flex Consumption or Premium are your options.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The environment-level private endpoint on ACA carries additional charges through the &lt;strong&gt;Dedicated Plan Management&lt;/strong&gt; fee. Budget roughly $67-70/month for this capability, which applies at the environment level regardless of how many apps you run inside it.&lt;/p&gt;

&lt;h3&gt;
  
  
  App Service: the predictable option
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;App Service&lt;/strong&gt; doesn't get much attention in the serverless conversation, but it's worth considering if your workload has predictable traffic and you've already left the scale-to-zero model behind. On Premium EP1, you're paying ~$146/month for a single function app that never scales to zero anyway. An App Service P1v3 plan gives you 2 vCPUs and 8 GB of memory. It supports multiple apps on the same plan, full deployment slots (up to 5 on Standard, 20 on Premium), and no cold starts. The pricing is comparable, and you get operational features that Functions on Premium doesn't match.&lt;/p&gt;

&lt;p&gt;App Service won't give you KEDA-based event scaling or scale-to-zero. It's a fixed-compute model. But if you're already paying for always-on compute through Premium Functions, the question is whether the Functions event-driven abstractions justify the constraints that come with them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Picking the right exit
&lt;/h3&gt;

&lt;p&gt;The choice depends on what pushed you away from Functions in the first place:&lt;/p&gt;

&lt;p&gt;If your main frustration is &lt;strong&gt;cost&lt;/strong&gt;, Container Apps on the Consumption workload profile gives you scale-to-zero with VNet support at a fraction of Premium pricing. You keep the Functions programming model, triggers, and bindings.&lt;/p&gt;

&lt;p&gt;If your frustration is &lt;strong&gt;operational control&lt;/strong&gt;, Container Apps gives you sidecars, revisions, and a container runtime you can customize. The trade-off is containerization overhead and the requirement to build and manage container images.&lt;/p&gt;

&lt;p&gt;Teams frustrated by &lt;strong&gt;complexity for a steady workload&lt;/strong&gt; often find that App Service is the right fit. It strips away the serverless machinery and gives you a predictable compute environment with mature deployment tooling.&lt;/p&gt;

&lt;p&gt;None of these are a universal upgrade. Each one trades a Functions limitation for a different set of constraints. The point is to make that trade consciously, not to discover it after migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making an Honest Choice
&lt;/h2&gt;

&lt;p&gt;Three plans, three different products.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consumption&lt;/strong&gt; is genuine serverless. Your code runs, you pay for execution time, it scales to zero when idle. If your workload is public-facing, doesn't need VNet access, and won't face a security review that mandates private networking, Consumption is the right plan. It does exactly what the marketing says. The catch: Linux Consumption enters restricted feature mode in September 2025, with full retirement in September 2028. New workloads shouldn't start here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flex Consumption&lt;/strong&gt; is serverless with enterprise networking. It went GA in November 2024 and it's the plan Microsoft recommends for new dynamic-scale workloads in 2026. You get VNet integration, inbound private endpoints, scale-to-zero, and no Azure Files dependency. The constraints are real (Linux only, one app per plan, no deployment slots, 30-second init timeout), but for teams that can work within them, Flex keeps the serverless economics intact while passing a security review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Premium&lt;/strong&gt; is managed compute with event-driven scaling. It is not serverless. You're paying for always-on instances whether traffic arrives or not. Premium exists because some requirements (Windows, deployment slots, in-process .NET, multiple apps per plan) have no other home. If you're on Premium, own that decision. Budget for it as compute, not as serverless with extra features.&lt;/p&gt;

&lt;p&gt;The distinction matters most at the moment you least want to think about it: during the security review, when someone asks why your function app can't reach a private endpoint. Know which plan you're actually buying before that conversation starts. Migrating between plans means deleting and recreating the function app, updating Terraform resources, and rewriting deployment pipelines. It's not a configuration change. It's a project.&lt;/p&gt;

&lt;p&gt;Has a security review pushed you from Consumption to Premium, or did you start on Premium from day one?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Azure Functions for .NET Developers: Series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: &lt;a href="https://dev.to/martin_oehlert/why-azure-functions-serverless-for-net-developers-707"&gt;Why Azure Functions? Serverless for .NET Developers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 2: &lt;a href="https://dev.to/martin_oehlert/your-first-azure-function-http-triggers-step-by-step-ib8"&gt;Your First Azure Function: HTTP Triggers Step-by-Step&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 3: &lt;a href="https://dev.to/martin_oehlert/beyond-http-timer-queue-and-blob-triggers-5aj5"&gt;Beyond HTTP: Timer, Queue, and Blob Triggers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 4: &lt;a href="https://dev.to/martin_oehlert/local-development-setup-tools-debugging-and-hot-reload-2925"&gt;Local Development Setup: Tools, Debugging, and Hot Reload&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 5: &lt;a href="https://dev.to/martin_oehlert/understanding-the-isolated-worker-model-5gd4"&gt;Understanding the Isolated Worker Model&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 6: &lt;a href="https://dev.to/martin_oehlert/configuration-done-right-settings-secrets-and-key-vault-3n7h"&gt;Configuration Done Right: Settings, Secrets, and Key Vault&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 7: &lt;a href="https://dev.to/martin_oehlert/testing-azure-functions-unit-integration-and-local-1cml"&gt;Testing Azure Functions: Unit, Integration, and Local&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 8: &lt;a href="https://dev.to/martin_oehlert/deploying-to-azure-cicd-with-github-actions-141m"&gt;Deploying to Azure: CI/CD with GitHub Actions&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 9: &lt;a href="https://dev.to/martin_oehlert/azure-functions-observability-from-blind-spots-to-production-clarity-24j4"&gt;Azure Functions Observability: From Blind Spots to Production Clarity&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bonus: Production Realities: When Serverless Stops Being Serverless (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>architecture</category>
    </item>
    <item>
      <title>Azure Functions Observability: From Blind Spots to Production Clarity</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 03 Apr 2026 06:25:08 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/azure-functions-observability-from-blind-spots-to-production-clarity-24j4</link>
      <guid>https://dev.to/martin_oehlert/azure-functions-observability-from-blind-spots-to-production-clarity-24j4</guid>
      <description>&lt;p&gt;Your function works locally, passes all tests, and deploys without errors. But how do you know it's healthy at 2am when a queue-triggered function silently drops messages? With a traditional web app on a VM, you'd SSH in, check logs, inspect process health. Serverless strips all of that away.&lt;/p&gt;

&lt;p&gt;The observability gap in serverless is real, and it's structural. Your function runs inside an ephemeral container that spins up on demand, processes an event, and disappears. There's no persistent server to monitor, no process to attach a debugger to, no &lt;code&gt;/var/log&lt;/code&gt; to tail. When your function app scales to zero between invocations, even continuous metric collection breaks down: there is literally nothing running to emit telemetry.&lt;/p&gt;

&lt;p&gt;And when it scales from zero to fifty concurrent instances under load, correlating a single failed request across that distributed execution becomes a different problem entirely. Traditional APM tools assume long-lived processes with stable identities. Serverless functions violate every one of those assumptions.&lt;/p&gt;

&lt;p&gt;Application Insights fills that gap. When connected to your function app (via a connection string, not the deprecated instrumentation key), it automatically captures request telemetry for every function execution, tracks dependencies like HTTP calls and database queries, collects host-level performance counters, and aggregates invocation metrics you can query from the portal or through code.&lt;/p&gt;

&lt;p&gt;On top of that, it gives you structured log queries with KQL (Kusto Query Language), an application map that visualizes how your function calls downstream services, distributed tracing that follows a single request across multiple functions and dependencies, and alerting rules that can page you before your users notice something is wrong.&lt;/p&gt;

&lt;p&gt;The examples below use the classic Application Insights SDK with the isolated worker model, which is what most production .NET function apps run today. The companion repository at &lt;a href="https://github.com/MO2k4/azure-functions-samples" rel="noopener noreferrer"&gt;azure-functions-samples&lt;/a&gt; has working examples of both the classic SDK (&lt;code&gt;HttpTriggerDemo&lt;/code&gt;) and OpenTelemetry (&lt;code&gt;EventHubDemo&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Application Insights
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Creating the Resource
&lt;/h3&gt;

&lt;p&gt;You can create an Application Insights resource through the Azure Portal (search "Application Insights" and click Create) or provision it with Bicep alongside your function app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: 'appi-orders-prod'
  location: location
  kind: 'web'
  properties: {
    Application_Type: 'web'
    WorkspaceResourceId: logAnalyticsWorkspace.id
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resource gives you a &lt;strong&gt;connection string&lt;/strong&gt;, which looks like &lt;code&gt;InstrumentationKey=&amp;lt;guid&amp;gt;;IngestionEndpoint=https://region.in.applicationinsights.azure.com/&lt;/code&gt;. Use this, not the instrumentation key alone. Microsoft deprecated standalone instrumentation key ingestion in March 2025, and connection strings are required for sovereign clouds, regional endpoints, and Entra ID-authenticated ingestion. Store the value in your function app's &lt;code&gt;APPLICATIONINSIGHTS_CONNECTION_STRING&lt;/code&gt; application setting, and Azure picks it up automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  NuGet Packages
&lt;/h3&gt;

&lt;p&gt;The isolated worker model needs two packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Microsoft.ApplicationInsights.WorkerService &lt;span class="nt"&gt;--version&lt;/span&gt; 2.22.0
dotnet add package Microsoft.Azure.Functions.Worker.ApplicationInsights &lt;span class="nt"&gt;--version&lt;/span&gt; 2.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pin &lt;code&gt;Microsoft.ApplicationInsights.WorkerService&lt;/code&gt; to 2.22.0.&lt;/strong&gt; Version 3.0.0 migrated to OpenTelemetry internally and broke the &lt;code&gt;ITelemetryInitializer&lt;/code&gt; interface that &lt;code&gt;Microsoft.Azure.Functions.Worker.ApplicationInsights&lt;/code&gt; depends on. The result is a &lt;code&gt;TypeLoadException&lt;/code&gt; at startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;System.TypeLoadException: Could not load type
'Microsoft.ApplicationInsights.Extensibility.ITelemetryInitializer'
from assembly 'Microsoft.ApplicationInsights, Version=3.0.0.1'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This affects every .NET version (not just .NET 10). Until the Functions worker package ships a compatible update, stay on 2.22.0. Add a comment in your &lt;code&gt;.csproj&lt;/code&gt; so the next person who runs &lt;code&gt;dotnet outdated&lt;/code&gt; doesn't blindly upgrade:&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;!-- Do NOT upgrade to 3.x: breaks Functions worker. See github.com/Azure/azure-functions-dotnet-worker/issues/3322 --&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.ApplicationInsights.WorkerService"&lt;/span&gt; &lt;span class="na"&gt;Version=&lt;/span&gt;&lt;span class="s"&gt;"2.22.0"&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.ApplicationInsights"&lt;/span&gt; &lt;span class="na"&gt;Version=&lt;/span&gt;&lt;span class="s"&gt;"2.0.0"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- Check https://github.com/Azure/azure-functions-dotnet-worker/issues/3322 before upgrading either package --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second package, &lt;code&gt;Microsoft.Azure.Functions.Worker.ApplicationInsights&lt;/code&gt;, is what connects your dependency telemetry (HTTP calls, SQL queries, queue operations) back to the parent function invocation. Without it, correlation breaks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Program.cs Configuration
&lt;/h3&gt;

&lt;p&gt;Two method calls handle the setup:&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.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="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="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddApplicationInsightsTelemetryWorkerService&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureFunctionsApplicationInsights&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;AddApplicationInsightsTelemetryWorkerService()&lt;/code&gt; registers the Application Insights SDK for worker-style apps (background services, Functions). &lt;code&gt;ConfigureFunctionsApplicationInsights()&lt;/code&gt; hooks into the Functions runtime's activity pipeline so that incoming triggers and outbound calls produce the right request and dependency telemetry.&lt;/p&gt;

&lt;p&gt;See this in context in the &lt;a href="https://github.com/MO2k4/azure-functions-samples/blob/main/HttpTriggerDemo/Program.cs" rel="noopener noreferrer"&gt;HttpTriggerDemo Program.cs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One catch: the SDK registers a default logging filter that suppresses everything below &lt;code&gt;Warning&lt;/code&gt;. If you leave it in place, your &lt;code&gt;ILogger.LogInformation()&lt;/code&gt; calls never reach Application Insights. Remove it explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;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.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;Microsoft.Extensions.Logging&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="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddApplicationInsightsTelemetryWorkerService&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureFunctionsApplicationInsights&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;Logging&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;LoggerFilterOptions&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="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;LoggerFilterRule&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;defaultRule&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;Rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProviderName&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt;
            &lt;span class="s"&gt;"Microsoft.Extensions.Logging.ApplicationInsights.ApplicationInsightsLoggerProvider"&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;defaultRule&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="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;Rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;defaultRule&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;Alternatively, manage log levels through &lt;code&gt;appsettings.json&lt;/code&gt; (loaded automatically by &lt;code&gt;FunctionsApplication.CreateBuilder&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Logging"&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;"LogLevel"&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;"Default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Information"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Microsoft"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Warning"&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;"ApplicationInsights"&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;"LogLevel"&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;"Default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Information"&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;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;h3&gt;
  
  
  What Gets Auto-Collected vs. What You Add Manually
&lt;/h3&gt;

&lt;p&gt;Once that's in place, the SDK and the Functions runtime collect telemetry automatically, with no extra code:&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%2F08iqaljtc0amwpqfevt9.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%2F08iqaljtc0amwpqfevt9.png" alt="Auto-collected vs manual telemetry" width="800" height="351"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha: Two Log Pipelines, Two Configurations&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The isolated worker model runs your code in a separate process from the Functions host. This means &lt;code&gt;host.json&lt;/code&gt; controls logging for the &lt;strong&gt;host process&lt;/strong&gt; (trigger dispatch, scaling decisions, extension lifecycle), while your &lt;code&gt;Program.cs&lt;/code&gt; or &lt;code&gt;appsettings.json&lt;/code&gt; controls logging for the &lt;strong&gt;worker process&lt;/strong&gt; (your function code, your dependencies, your &lt;code&gt;ILogger&lt;/code&gt; calls). If you set &lt;code&gt;"Default": "Information"&lt;/code&gt; in &lt;code&gt;host.json&lt;/code&gt; but never configure your worker, your application logs still default to &lt;code&gt;Warning&lt;/code&gt; only. You have to configure both sides, and they use different files.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Logging Best Practices
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Structured Logging with ILogger
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Structured logging&lt;/strong&gt; writes log entries as key-value pairs instead of flat strings. In Application Insights, those keys become columns in the &lt;code&gt;customDimensions&lt;/code&gt; property of the &lt;code&gt;traces&lt;/code&gt; table, which means you can filter and aggregate by them in KQL without parsing text.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ILogger&lt;/code&gt; API supports this through &lt;strong&gt;message templates&lt;/strong&gt;: named placeholders wrapped in curly braces, filled by positional arguments.&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;ProcessOrderFunction&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;ProcessOrderFunction&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;ProcessOrderFunction&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;"orders"&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="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;"Order received: {OrderId} from customer {CustomerId}, total {OrderTotal}"&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="n"&gt;message&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;message&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;stopwatch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Stopwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartNew&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;stopwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Stop&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;"Order {OrderId} processed successfully in {ElapsedMs}ms"&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="n"&gt;stopwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ElapsedMilliseconds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to watch here. First, use &lt;strong&gt;PascalCase&lt;/strong&gt; for placeholder names (&lt;code&gt;{OrderId}&lt;/code&gt;, not &lt;code&gt;{orderId}&lt;/code&gt; or &lt;code&gt;{order_id}&lt;/code&gt;). Application Insights stores these as &lt;code&gt;customDimensions&lt;/code&gt; keys, and PascalCase matches the convention for the rest of the telemetry schema. Second, never use string interpolation (&lt;code&gt;$"Order {orderId}"&lt;/code&gt;) in log calls. Interpolated strings defeat structured logging entirely: the provider receives a pre-formatted string with no queryable fields, and the arguments are evaluated even when the log level is disabled.&lt;/p&gt;

&lt;p&gt;In the Application Insights &lt;code&gt;traces&lt;/code&gt; table, a query like this pulls all logs for a specific order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;traces
| where customDimensions.OrderId == "ORD-20260330-1847"
| project timestamp, message, customDimensions.CustomerId, customDimensions.ElapsedMs
| order by timestamp asc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  High-Performance Logging with Source Generators
&lt;/h3&gt;

&lt;p&gt;For functions processing thousands of messages per second (high-throughput queue or Event Hub triggers), the standard &lt;code&gt;LogInformation&lt;/code&gt; extension methods have measurable overhead: they box value-type arguments, allocate a &lt;code&gt;params object[]&lt;/code&gt; on every call, and parse the message template at runtime.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;&lt;code&gt;[LoggerMessage]&lt;/code&gt; source generator&lt;/strong&gt; eliminates all three costs by generating strongly typed methods at compile time:&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;partial&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderLogs&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;LoggerMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;EventId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&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="s"&gt;"Order {OrderId} received from {CustomerId}, total {OrderTotal}"&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;partial&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;OrderReceived&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="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;orderTotal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;LoggerMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;EventId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warning&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="s"&gt;"Order {OrderId} retry attempt {RetryCount}"&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;partial&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;OrderRetrying&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="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;retryCount&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;Call these directly: &lt;code&gt;OrderLogs.OrderReceived(logger, message.OrderId, message.CustomerId, message.Total)&lt;/code&gt;. The generated code includes a log-level check before evaluating any arguments, so you pay zero cost when Information-level logging is disabled in production. Use this pattern on hot paths; for functions running a few times per minute, the standard extension methods are fine. The companion repo has source generator examples in both &lt;a href="https://github.com/MO2k4/azure-functions-samples/blob/main/HttpTriggerDemo/Logging/OrderLogs.cs" rel="noopener noreferrer"&gt;HttpTriggerDemo/Logging/OrderLogs.cs&lt;/a&gt; and &lt;a href="https://github.com/MO2k4/azure-functions-samples/blob/main/EventHubDemo/Logging/SensorLogs.cs" rel="noopener noreferrer"&gt;EventHubDemo/Logging/SensorLogs.cs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Correlation with BeginScope
&lt;/h3&gt;

&lt;p&gt;Individual log lines tell you what happened; &lt;strong&gt;BeginScope&lt;/strong&gt; ties related entries into a single operation. When you wrap your function body in a scope, every log entry inside it automatically inherits the scope's properties as &lt;code&gt;customDimensions&lt;/code&gt; in Application Insights.&lt;/p&gt;

&lt;p&gt;The critical detail: you must pass a &lt;code&gt;Dictionary&amp;lt;string, object&amp;gt;&lt;/code&gt; to &lt;code&gt;BeginScope&lt;/code&gt; for the keys to appear as individual &lt;code&gt;customDimensions&lt;/code&gt; columns. A plain string or a message template with arguments produces a single formatted string in the scope, which is much harder to query.&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;ProcessOrderFunction&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;"orders"&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="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;scope&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;BeginScope&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;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;object&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;"OrderId"&lt;/span&gt;&lt;span class="p"&gt;]&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="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"CustomerId"&lt;/span&gt;&lt;span class="p"&gt;]&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;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"TenantId"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TenantId&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="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;"Validating order"&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;ValidateAsync&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;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;"Charging payment"&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;ChargePaymentAsync&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;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;"Order complete"&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 log line inside that &lt;code&gt;using&lt;/code&gt; block now carries &lt;code&gt;OrderId&lt;/code&gt;, &lt;code&gt;CustomerId&lt;/code&gt;, and &lt;code&gt;TenantId&lt;/code&gt; in its &lt;code&gt;customDimensions&lt;/code&gt;, even the ones from &lt;code&gt;ValidateAsync&lt;/code&gt; and &lt;code&gt;ChargePaymentAsync&lt;/code&gt; (assuming they use the same &lt;code&gt;ILogger&lt;/code&gt; instance). This is how you trace a complete business operation across multiple internal methods without threading correlation IDs through every method signature.&lt;/p&gt;

&lt;h3&gt;
  
  
  Log Levels for Production
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;host.json&lt;/code&gt; &lt;strong&gt;logLevel&lt;/strong&gt; section controls which categories reach Application Insights. Two categories are easy to misconfigure, and getting them wrong silently breaks your monitoring dashboards.&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;"logging"&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;"logLevel"&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;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Warning"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Function"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Information"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Host.Results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Information"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Host.Aggregator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Trace"&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;&lt;strong&gt;&lt;code&gt;Host.Results&lt;/code&gt;&lt;/strong&gt; feeds the &lt;code&gt;requests&lt;/code&gt; table. If you raise this above &lt;code&gt;Information&lt;/code&gt;, successful function executions stop appearing in the Application Insights Performance and Failures blades, and the Function Monitor tab in the portal goes blank. You lose your primary visibility into whether functions are running at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Host.Aggregator&lt;/code&gt;&lt;/strong&gt; feeds the &lt;code&gt;customMetrics&lt;/code&gt; table with aggregated counts and durations. Set it to &lt;code&gt;Trace&lt;/code&gt; so the runtime writes every batch. If you raise this to &lt;code&gt;Warning&lt;/code&gt; or higher, the function overview dashboard in the portal loses its success rate and duration charts.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;default: Warning&lt;/code&gt; baseline keeps noise low for framework categories (&lt;code&gt;Microsoft.*&lt;/code&gt;, &lt;code&gt;Worker&lt;/code&gt;, &lt;code&gt;System.*&lt;/code&gt;) while &lt;code&gt;Function: Information&lt;/code&gt; ensures your own function logs reach Application Insights.&lt;/p&gt;

&lt;p&gt;When you need to change log levels without redeploying, override any &lt;code&gt;host.json&lt;/code&gt; value through app settings. The pattern replaces dots with double underscores:&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;AzureFunctionsJobHost__logging__logLevel__Function.ProcessOrder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;Debug&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This takes effect on the next function host restart (which happens automatically when you update an app setting) and lets you temporarily increase verbosity for a single function without touching &lt;code&gt;host.json&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sampling Configuration
&lt;/h3&gt;

&lt;p&gt;Application Insights enables &lt;strong&gt;adaptive sampling&lt;/strong&gt; by default, targeting 20 telemetry items per second per host. At low volume, you won't notice. At scale, sampling can silently discard traces, dependencies, and custom events before they reach your workspace.&lt;/p&gt;

&lt;p&gt;The recommended production configuration excludes &lt;code&gt;Request&lt;/code&gt; and &lt;code&gt;Exception&lt;/code&gt; from sampling, so you never lose function execution records or error details:&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;"logging"&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;"applicationInsights"&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;"samplingSettings"&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;"isEnabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;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;"maxTelemetryItemsPerSecond"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"excludedTypes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Request;Exception"&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;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 check whether sampling is actively dropping data, run this KQL query. Any row where &lt;code&gt;TelemetrySavedPercentage&lt;/code&gt; is below 100 means that telemetry type is being sampled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;union traces, dependencies, requests
| where timestamp &amp;gt; ago(1d)
| summarize
    TelemetrySavedPercentage = round(100.0 / avg(itemCount), 1),
    TelemetryDroppedPercentage = round(100.0 - 100.0 / avg(itemCount), 1)
    by bin(timestamp, 1h), itemType
| where TelemetrySavedPercentage &amp;lt; 100
| order by timestamp desc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;itemCount&lt;/code&gt; field on each telemetry item tells you how many similar items it represents. An &lt;code&gt;itemCount&lt;/code&gt; of 5 means Application Insights kept one item and estimated it represents five. If your &lt;code&gt;traces&lt;/code&gt; show 30% dropped, either raise &lt;code&gt;maxTelemetryItemsPerSecond&lt;/code&gt; or add &lt;code&gt;Trace&lt;/code&gt; to &lt;code&gt;excludedTypes&lt;/code&gt; for the categories that matter most to your debugging workflow. Watch your ingestion costs, though: excluding too many types from sampling at high volume can push you past your daily data cap quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading Traces and Metrics
&lt;/h2&gt;

&lt;p&gt;Once telemetry is flowing into Application Insights, you need to know where to look and what to ask. The portal gives you three entry points: Transaction Search for hunting specific executions, Log queries (KQL) for anything that requires aggregation or correlation, and the Application Map for a visual snapshot of your function app's dependencies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Transaction Search
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Transaction Search&lt;/strong&gt; is the fastest way to find what happened to a specific function execution. Open it from the left nav in your Application Insights resource, or use the shortcut from the Investigate section of the overview blade.&lt;/p&gt;

&lt;p&gt;The filters that matter most for Azure Functions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Operation name&lt;/strong&gt;: the function name as registered in the runtime (e.g., &lt;code&gt;ProcessOrderFunction&lt;/code&gt;). Filter here when you want all executions of a specific function in a time window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Result code&lt;/strong&gt;: for HTTP triggers, this is the HTTP status code (&lt;code&gt;200&lt;/code&gt;, &lt;code&gt;500&lt;/code&gt;, etc.). For non-HTTP triggers (queue, timer, blob), &lt;code&gt;0&lt;/code&gt; means success and &lt;code&gt;1&lt;/code&gt; means failure. Combine with operation name to pull only failed runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time range&lt;/strong&gt;: narrow this first, before adding other filters. Application Insights searches can time out on broad time ranges at high volume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Click any result to open the &lt;strong&gt;End-to-end transaction view&lt;/strong&gt;. This is where distributed tracing pays off: you'll see the full execution timeline as a Gantt chart, with your function's request at the top and every outbound dependency (HTTP calls to payment APIs, SQL queries, Service Bus operations) shown as child spans with their durations. If a queue-triggered &lt;code&gt;ProcessOrderFunction&lt;/code&gt; call failed at 2am, this view tells you whether the failure was in your code, in a downstream HTTP call, or in a database query.&lt;/p&gt;

&lt;p&gt;One limitation: Transaction Search shows individual telemetry items, not aggregated data. If you want to know "how many orders failed between 1am and 3am and which customer IDs were affected", you need KQL.&lt;/p&gt;

&lt;h3&gt;
  
  
  KQL Essentials for Azure Functions
&lt;/h3&gt;

&lt;p&gt;All four tables you'll use most (&lt;code&gt;requests&lt;/code&gt;, &lt;code&gt;dependencies&lt;/code&gt;, &lt;code&gt;traces&lt;/code&gt;, &lt;code&gt;exceptions&lt;/code&gt;) share an &lt;code&gt;operation_Id&lt;/code&gt; column. That ID is the distributed trace ID that ties every log line, dependency call, and exception back to the single function invocation that produced them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finding slow executions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;requests&lt;/code&gt; table records every function invocation. &lt;code&gt;duration&lt;/code&gt; is in milliseconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requests
| where timestamp &amp;gt; ago(24h)
| where name == "ProcessOrderFunction"
| where duration &amp;gt; 5000
| project timestamp, id, duration, resultCode, operation_Id
| order by duration desc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you the slowest &lt;code&gt;ProcessOrderFunction&lt;/code&gt; executions in the last 24 hours. Swap &lt;code&gt;&amp;gt; 5000&lt;/code&gt; for whatever your SLA threshold is. The &lt;code&gt;operation_Id&lt;/code&gt; in each row is your entry point into the full trace for that execution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tracing a single request end-to-end&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You have an &lt;code&gt;operation_Id&lt;/code&gt; from a failed execution (from Transaction Search, from an alert, or from a support ticket). This query reconstructs everything that happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let traceId = "abc123def456";
union requests, dependencies, traces, exceptions
| where operation_Id == traceId
| project timestamp, itemType, name, message, duration, success, resultCode, customDimensions
| order by timestamp asc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;union&lt;/code&gt; across all four tables is deliberate. A single function execution produces rows in multiple tables: a &lt;code&gt;requests&lt;/code&gt; row for the invocation itself, &lt;code&gt;dependencies&lt;/code&gt; rows for every outbound call, &lt;code&gt;traces&lt;/code&gt; rows for your &lt;code&gt;ILogger&lt;/code&gt; calls, and an &lt;code&gt;exceptions&lt;/code&gt; row if something threw. The &lt;code&gt;itemType&lt;/code&gt; column tells you which table each row came from.&lt;/p&gt;

&lt;p&gt;If you set up &lt;code&gt;BeginScope&lt;/code&gt; with &lt;code&gt;OrderId&lt;/code&gt; and &lt;code&gt;CustomerId&lt;/code&gt; as described in the logging section, those values appear in &lt;code&gt;customDimensions&lt;/code&gt; on every trace row. You can also work backwards from a business ID when you don't have an &lt;code&gt;operation_Id&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;traces
| where timestamp &amp;gt; ago(24h)
| where customDimensions.OrderId == "ORD-20260330-1847"
| project operation_Id
| distinct operation_Id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take that &lt;code&gt;operation_Id&lt;/code&gt; and feed it into the union query above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Counting failures by function name over time&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requests
| where timestamp &amp;gt; ago(7d)
| where success == false
| summarize FailureCount = count() by bin(timestamp, 1h), name
| order by timestamp desc, FailureCount desc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This surfaces which functions fail most, and whether failures cluster at specific times (a sign of a dependency being unhealthy during a maintenance window, or a batch job hitting a resource limit).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finding dependency bottlenecks&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your function may be fast; a downstream service may not be.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dependencies
| where timestamp &amp;gt; ago(24h)
| where cloud_RoleName == "your-function-app-name"
| summarize
    CallCount = count(),
    P50 = percentile(duration, 50),
    P95 = percentile(duration, 95),
    P99 = percentile(duration, 99),
    FailureRate = round(100.0 * countif(success == false) / count(), 1)
    by target, type
| order by P95 desc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;"your-function-app-name"&lt;/code&gt; with the value in your function app's Application Insights configuration (it defaults to the function app name). The &lt;code&gt;target&lt;/code&gt; column shows the external endpoint or database, and &lt;code&gt;type&lt;/code&gt; shows the dependency kind (&lt;code&gt;HTTP&lt;/code&gt;, &lt;code&gt;SQL&lt;/code&gt;, &lt;code&gt;Azure Service Bus&lt;/code&gt;, etc.). A high &lt;code&gt;P95&lt;/code&gt; with a low &lt;code&gt;FailureRate&lt;/code&gt; means the dependency is slow but not failing outright: the kind of problem that shows up as user-visible latency before it shows up as errors.&lt;/p&gt;

&lt;p&gt;One gotcha with KQL in the portal: queries run against a Log Analytics workspace, and there's a default query scope. If you open KQL from the Application Insights blade, you're automatically scoped to that resource's workspace. If you open it from a general Log Analytics workspace, you need to add &lt;code&gt;| where cloud_RoleName == "your-function-app-name"&lt;/code&gt; to every query, or you'll get results mixed across all resources in the workspace.&lt;/p&gt;

&lt;h3&gt;
  
  
  Application Map
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;Application Map&lt;/strong&gt; (left nav, under Investigate) renders your function app as a node and every dependency it calls as connected nodes. Each connection shows call volume, average duration, and failure rate. Nodes turn yellow when failure rates exceed roughly 20-30% and red above 50% (the thresholds aren't configurable).&lt;/p&gt;

&lt;p&gt;For a &lt;code&gt;ProcessOrderFunction&lt;/code&gt; that calls a payment API and writes to SQL, you'd see three nodes: your function app in the centre, the payment API to one side, and the SQL database to the other. The lines between them show call counts and P95 latency. If the payment API node is yellow, that's your first place to look during an incident.&lt;/p&gt;

&lt;p&gt;The map is useful for a quick health check and for onboarding new team members, but it has limits. It aggregates across all functions in the app, so if you have ten functions and one is hammering a slow dependency, the map shows the aggregate. It also doesn't distinguish between functions calling the same dependency: if both &lt;code&gt;ProcessOrderFunction&lt;/code&gt; and &lt;code&gt;RefundOrderFunction&lt;/code&gt; call the same SQL database, the database shows one aggregated node. For function-level dependency analysis, go back to the KQL query above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alerts
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Alert rules&lt;/strong&gt; in Application Insights let you define a condition and trigger an action group (email, Teams webhook, PagerDuty, etc.) when the condition is met. You configure them under the Alerts section of your Application Insights resource.&lt;/p&gt;

&lt;p&gt;To create a failure rate alert for &lt;code&gt;ProcessOrderFunction&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Select &lt;strong&gt;Create &amp;gt; Alert rule&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Set the signal to &lt;strong&gt;Custom log search&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Use this KQL as the condition:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requests
| where name == "ProcessOrderFunction"
| where timestamp &amp;gt; ago(5m)
| summarize
    Total = count(),
    Failed = countif(success == false)
| extend FailureRate = round(100.0 * Failed / Total, 1)
| where FailureRate &amp;gt; 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Set evaluation frequency to every 1 minute, and the lookback window to 5 minutes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Configure the threshold: alert when the query returns any rows (meaning &lt;code&gt;FailureRate &amp;gt; 5&lt;/code&gt; for that window).&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Action groups&lt;/strong&gt; are the notification mechanism. One action group can send email, post to a Teams incoming webhook, and call an Azure Automation runbook simultaneously. Define your on-call action group once, then reuse it across all alert rules.&lt;/p&gt;

&lt;p&gt;A few practical notes on alert tuning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start with a 5-minute window and a 5% threshold, then tighten after you've seen a few weeks of baseline data. Alerting on 1-minute windows at 1% failure rate on a low-volume function produces a lot of noise for transient errors.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;requests&lt;/code&gt; table has a 1-2 minute ingestion delay under normal conditions and up to 5 minutes during ingestion spikes. A 5-minute lookback window accounts for this. A 1-minute window can miss failures entirely if ingestion is delayed.&lt;/li&gt;
&lt;li&gt;For queue-triggered functions, complement failure rate alerts with a &lt;strong&gt;queue depth alert&lt;/strong&gt; on the source queue (configured through Azure Monitor metrics, not Application Insights). A growing queue combined with low invocation count means your function is failing at startup, before it even executes: a scenario that produces no &lt;code&gt;requests&lt;/code&gt; rows and won't trigger a failure rate alert.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common Issues and Fixes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "My logs aren't showing up in Application Insights"
&lt;/h3&gt;

&lt;p&gt;It comes down to one of four things, and you can rule them out in order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the connection string first.&lt;/strong&gt; Open your function app in the portal, go to Configuration, and confirm &lt;code&gt;APPLICATIONINSIGHTS_CONNECTION_STRING&lt;/code&gt; is set and points to the right resource. If it's missing or set to an instrumentation key only (the &lt;code&gt;InstrumentationKey=&amp;lt;guid&amp;gt;&lt;/code&gt; format without an &lt;code&gt;IngestionEndpoint&lt;/code&gt;), nothing reaches Application Insights at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the worker log level config.&lt;/strong&gt; As covered in the two-pipeline gotcha above: &lt;code&gt;host.json&lt;/code&gt; controls the host process, but your &lt;code&gt;Program.cs&lt;/code&gt; or &lt;code&gt;appsettings.json&lt;/code&gt; controls the worker. If you haven't explicitly configured the worker's &lt;code&gt;ApplicationInsights&lt;/code&gt; log level, the SDK default applies: &lt;code&gt;Warning&lt;/code&gt; only. Add this to your &lt;code&gt;appsettings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Logging"&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;"ApplicationInsights"&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;"LogLevel"&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;"Default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Information"&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;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;Or remove the default filter rule entirely in &lt;code&gt;Program.cs&lt;/code&gt;, as shown in the setup section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check sampling.&lt;/strong&gt; If requests appear but traces for specific functions don't, sampling may be discarding them. Run the KQL query from the sampling section to see which telemetry types are being dropped. Add &lt;code&gt;Trace&lt;/code&gt; to &lt;code&gt;excludedTypes&lt;/code&gt; in &lt;code&gt;host.json&lt;/code&gt; if you need full trace fidelity for a critical function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the &lt;code&gt;Function&lt;/code&gt; log level in &lt;code&gt;host.json&lt;/code&gt;.&lt;/strong&gt; If &lt;code&gt;Function&lt;/code&gt; is set to &lt;code&gt;Warning&lt;/code&gt; or higher, &lt;code&gt;LogInformation&lt;/code&gt; calls from your function code never leave the host. Set it to &lt;code&gt;Information&lt;/code&gt; to restore them.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Dependencies are missing from the Application Map"
&lt;/h3&gt;

&lt;p&gt;When your Application Map shows your function app as an isolated node with no outbound edges, check for a missing &lt;code&gt;ConfigureFunctionsApplicationInsights()&lt;/code&gt; call in &lt;code&gt;Program.cs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AddApplicationInsightsTelemetryWorkerService()&lt;/code&gt; registers the SDK. &lt;code&gt;ConfigureFunctionsApplicationInsights()&lt;/code&gt; is what connects the Functions runtime's &lt;code&gt;ActivitySource&lt;/code&gt; to that SDK so outbound HTTP, SQL, and Azure SDK calls produce dependency telemetry with the correct operation IDs. Without it, dependencies are either not tracked at all or tracked with broken correlation (they appear in the &lt;code&gt;dependencies&lt;/code&gt; table but don't link back to the parent request).&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;AddApplicationInsightsTelemetryWorkerService&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureFunctionsApplicationInsights&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Required for dependency tracking&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If both calls are present and you're still missing HTTP dependencies: check how &lt;code&gt;HttpClient&lt;/code&gt; is registered. The Application Insights SDK instruments &lt;code&gt;HttpClient&lt;/code&gt; via &lt;code&gt;IHttpClientFactory&lt;/code&gt;. If you're creating &lt;code&gt;HttpClient&lt;/code&gt; instances with &lt;code&gt;new HttpClient()&lt;/code&gt; directly instead of injecting an &lt;code&gt;IHttpClientFactory&lt;/code&gt;-managed instance, those calls bypass the instrumentation entirely.&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;// Not tracked&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;_client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HttpClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Tracked (inject IHttpClientFactory via primary constructor)&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IHttpClientFactory&lt;/span&gt; &lt;span class="n"&gt;httpClientFactory&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;readonly&lt;/span&gt; &lt;span class="n"&gt;HttpClient&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;httpClientFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateClient&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;Register it 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="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHttpClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  "I see duplicate telemetry for every request"
&lt;/h3&gt;

&lt;p&gt;In the isolated worker model, both the host process and the worker process can emit telemetry for the same function invocation. When both are sending to the same Application Insights resource, you get duplicate &lt;code&gt;requests&lt;/code&gt; entries, inflated counts, and misleading failure rates.&lt;/p&gt;

&lt;p&gt;This is controlled by the &lt;code&gt;telemetryMode&lt;/code&gt; setting at the &lt;strong&gt;root level&lt;/strong&gt; of &lt;code&gt;host.json&lt;/code&gt; (not inside &lt;code&gt;logging&lt;/code&gt;). The default is &lt;code&gt;default&lt;/code&gt;, which allows both sides to emit. Setting it to &lt;code&gt;OpenTelemetry&lt;/code&gt; resolves the duplication, but note that when you do, the &lt;code&gt;logging.applicationInsights&lt;/code&gt; section of &lt;code&gt;host.json&lt;/code&gt; no longer applies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"telemetryMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OpenTelemetry"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alternatively, suppress host-side request telemetry while keeping your worker-side telemetry by raising &lt;code&gt;Host.Results&lt;/code&gt; above &lt;code&gt;Information&lt;/code&gt; in &lt;code&gt;host.json&lt;/code&gt;'s &lt;code&gt;logLevel&lt;/code&gt; section. The tradeoff: this also removes successful execution records from the portal's Function Monitor tab. Use &lt;code&gt;telemetryMode&lt;/code&gt; when you want clean deduplication without losing host-side visibility.&lt;/p&gt;

&lt;p&gt;To confirm duplication before changing anything, run this in KQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requests
| where timestamp &amp;gt; ago(1h)
| summarize count() by operation_Id
| where count_ &amp;gt; 1
| order by count_ desc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any &lt;code&gt;operation_Id&lt;/code&gt; appearing more than once is a duplicated invocation.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Cold start latency spikes in my metrics"
&lt;/h3&gt;

&lt;p&gt;Cold starts produce latency spikes that look identical to slow execution in your metrics. Before investigating application code, confirm whether a spike is a cold start or an actual regression.&lt;/p&gt;

&lt;p&gt;A cold start request carries a specific pattern: high latency on the first invocation from a given instance, with subsequent requests from the same instance running at normal duration. The &lt;code&gt;cloud_RoleInstance&lt;/code&gt; dimension on each request record identifies the instance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requests
| where timestamp &amp;gt; ago(24h)
| where name == "ProcessOrderFunction"
| summarize
    first_request = min(timestamp),
    p50 = percentile(duration, 50),
    p99 = percentile(duration, 99),
    request_count = count()
    by cloud_RoleInstance
| extend is_cold_start_instance = (request_count &amp;lt;= 3)
| order by first_request desc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instances where &lt;code&gt;request_count&lt;/code&gt; is 1 or 2 are almost certainly fresh scale-out instances, and their durations are not representative of your steady-state performance. Filter them out when computing your SLA metrics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requests
| where timestamp &amp;gt; ago(24h)
| where name == "ProcessOrderFunction"
| join kind=inner (
    requests
    | summarize request_count = count() by cloud_RoleInstance
    | where request_count &amp;gt; 5
) on cloud_RoleInstance
| summarize p50 = percentile(duration, 50), p99 = percentile(duration, 99)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the spike appears in warm instances, you have a real slowdown. If it's limited to fresh instances appearing after a scale-out event, it's cold start behavior. The two require different responses: cold starts call for pre-warming strategies or Consumption to Premium plan migration; actual slowdowns point to profiling.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Alerts fire but I can't find the failing requests"
&lt;/h3&gt;

&lt;p&gt;You set up an alert on exception count, it fires, you open the Failures blade, and the requests that caused the exceptions are gone. Sampling is discarding the evidence.&lt;/p&gt;

&lt;p&gt;By default, &lt;code&gt;Exception&lt;/code&gt; telemetry is sampled alongside everything else. When the SDK keeps one exception and estimates it represents five, the other four are discarded permanently. Your alert fires because the metric aggregation (which runs before sampling discards anything) saw all five. Your query returns only the one that survived.&lt;/p&gt;

&lt;p&gt;The fix is to exclude &lt;code&gt;Exception&lt;/code&gt; from sampling in &lt;code&gt;host.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"logging"&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;"applicationInsights"&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;"samplingSettings"&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;"isEnabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;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;"maxTelemetryItemsPerSecond"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"excludedTypes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Request;Exception"&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;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;Adding &lt;code&gt;Request&lt;/code&gt; to &lt;code&gt;excludedTypes&lt;/code&gt; ensures the parent request record is also always kept, so you can correlate the exception back to its invocation through the &lt;code&gt;operation_Id&lt;/code&gt;. Without both, you may find the exception but not the request that caused it.&lt;/p&gt;

&lt;p&gt;If the alert is on a custom metric rather than exceptions, check whether &lt;code&gt;customMetrics&lt;/code&gt; is being sampled. Custom metrics emitted through &lt;code&gt;TelemetryClient.GetMetric()&lt;/code&gt; are not affected by sampling (they're pre-aggregated in the SDK before sending). Custom events emitted with &lt;code&gt;TelemetryClient.TrackEvent()&lt;/code&gt; are sampled, and alerts based on custom event counts can suffer the same problem. Add &lt;code&gt;Event&lt;/code&gt; to &lt;code&gt;excludedTypes&lt;/code&gt; if that's your signal source.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenTelemetry Alternative
&lt;/h2&gt;

&lt;p&gt;The classic Application Insights SDK works well if your entire stack lives in Azure. But if you need to send telemetry to Grafana, Datadog, Jaeger, or any other backend alongside (or instead of) Azure Monitor, you're duplicating instrumentation code for each target. &lt;strong&gt;OpenTelemetry&lt;/strong&gt; solves this at the protocol level: one set of instrumentation, one exporter interface, multiple backends.&lt;/p&gt;

&lt;p&gt;OpenTelemetry is a CNCF project that defines a vendor-neutral API and wire format (OTLP) for traces, metrics, and logs. The same instrumentation code that sends data to Application Insights can also send to Zipkin or Collector pipelines with a config change, not a code rewrite.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Setup
&lt;/h3&gt;

&lt;p&gt;Microsoft publishes the &lt;strong&gt;&lt;code&gt;Microsoft.Azure.Functions.Worker.OpenTelemetry&lt;/code&gt;&lt;/strong&gt; package for this purpose, paired with the Azure Monitor exporter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Microsoft.Azure.Functions.Worker.OpenTelemetry
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package Azure.Monitor.OpenTelemetry.Exporter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, enable OpenTelemetry output from the Functions host by adding &lt;code&gt;"telemetryMode": "OpenTelemetry"&lt;/code&gt; at the root of your &lt;code&gt;host.json&lt;/code&gt; (the same setting described in the duplicate telemetry section). Then the &lt;code&gt;Program.cs&lt;/code&gt; registration replaces the classic SDK calls:&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.Monitor.OpenTelemetry.Exporter&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&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.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.Azure.Functions.Worker.OpenTelemetry&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="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="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseFunctionsWorkerDefaults&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAzureMonitorExporter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// reads APPLICATIONINSIGHTS_CONNECTION_STRING automatically&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;UseFunctionsWorkerDefaults()&lt;/code&gt; hooks into the Functions runtime's &lt;code&gt;ActivitySource&lt;/code&gt; for proper distributed trace correlation (the OpenTelemetry equivalent of &lt;code&gt;ConfigureFunctionsApplicationInsights()&lt;/code&gt; from the classic SDK). Without it, dependency telemetry won't correlate back to the parent function invocation. See the full setup in &lt;a href="https://github.com/MO2k4/azure-functions-samples/blob/main/EventHubDemo/Program.cs" rel="noopener noreferrer"&gt;EventHubDemo/Program.cs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The connection string is still required; &lt;code&gt;UseAzureMonitorExporter()&lt;/code&gt; reads &lt;code&gt;APPLICATIONINSIGHTS_CONNECTION_STRING&lt;/code&gt; from the environment the same way the classic SDK does. If you also want to export to a second backend (requires the additional &lt;code&gt;OpenTelemetry.Exporter.OpenTelemetryProtocol&lt;/code&gt; package), register it separately:&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;AddOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseFunctionsWorkerDefaults&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAzureMonitorExporter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithTracing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tracing&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tracing&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOtlpExporter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otlp&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;otlp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:4317"&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;UseAzureMonitorExporter()&lt;/code&gt; is a cross-cutting registration that configures all signals at once. Chaining signal-specific exporters like &lt;code&gt;AddOtlpExporter&lt;/code&gt; after it in the same builder can throw a &lt;code&gt;NotSupportedException&lt;/code&gt;. Separate &lt;code&gt;AddOpenTelemetry()&lt;/code&gt; calls avoid the conflict.&lt;/p&gt;

&lt;h3&gt;
  
  
  What You Gain
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;W3C Trace Context&lt;/strong&gt; is the default propagation format, which means your distributed traces correlate correctly with other OpenTelemetry-instrumented services regardless of what backend they report to. With the classic SDK you get this too, but only within the Application Insights ecosystem; outside it, the format diverges.&lt;/p&gt;

&lt;p&gt;You also get &lt;strong&gt;multi-backend export&lt;/strong&gt;: Azure Monitor for your ops team, a Grafana stack for your platform team, and a local Collector for local debugging, all from the same process. And if you ever migrate off Azure Monitor entirely, you replace one exporter registration, not every &lt;code&gt;TelemetryClient&lt;/code&gt; call in your codebase.&lt;/p&gt;

&lt;h3&gt;
  
  
  What You Lose Today
&lt;/h3&gt;

&lt;p&gt;The OpenTelemetry path is not at feature parity with the classic SDK for Functions specifically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live Metrics&lt;/strong&gt; (the real-time stream at &lt;code&gt;monitor.azure.com&lt;/code&gt;) does not work with the distro. It relies on a proprietary push mechanism in the classic SDK that has no OpenTelemetry equivalent yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snapshot Debugger&lt;/strong&gt; is unavailable. It's a classic SDK feature with no OTLP counterpart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto-collection gaps&lt;/strong&gt;: some dependency types that the classic SDK instruments automatically (certain Azure SDK operations, Service Bus settlement calls) may not be captured out of the box, depending on which OpenTelemetry instrumentation libraries you've added. You may need to add &lt;code&gt;AddAzureClientsInstrumentation()&lt;/code&gt; or equivalent packages explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documentation&lt;/strong&gt;: the distro's documentation for Functions scenarios specifically is thin. Most samples target ASP.NET Core web apps; you'll spend time adapting them and testing whether auto-collection works for your trigger types.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Choose Which
&lt;/h3&gt;

&lt;p&gt;Use the &lt;strong&gt;classic SDK&lt;/strong&gt; if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your entire workload runs on Azure and you have no multi-vendor requirements&lt;/li&gt;
&lt;li&gt;you need Live Metrics or Snapshot Debugger&lt;/li&gt;
&lt;li&gt;you want the richest out-of-the-box experience with the Application Insights portal today&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use &lt;strong&gt;OpenTelemetry&lt;/strong&gt; if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you're sending telemetry to multiple backends, or planning to&lt;/li&gt;
&lt;li&gt;the rest of your services are already OpenTelemetry-instrumented and you need consistent trace propagation across the board&lt;/li&gt;
&lt;li&gt;you're building something that might not always live in Azure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're greenfield on a purely Azure stack, the classic SDK is less configuration for the same result right now. If you're instrumenting a heterogeneous system or building for portability, OpenTelemetry's overhead is worth it; you pay once at setup and gain the flexibility when requirements change.&lt;/p&gt;

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

&lt;p&gt;This is Part 9 and the final article in the core series. Over nine weeks, this series went from "what is serverless" to querying production telemetry in KQL. If you followed along and built something, you now have a function app with HTTP and queue triggers, proper configuration with Key Vault, a CI/CD pipeline through GitHub Actions, and Application Insights wired up for structured logging, distributed tracing, and alerting. That covers the full lifecycle: build, test, deploy, monitor.&lt;/p&gt;

&lt;p&gt;The companion repository at &lt;a href="https://github.com/MO2k4/azure-functions-samples" rel="noopener noreferrer"&gt;azure-functions-samples&lt;/a&gt; has working code for every article in the series. Clone it, break things, wire up your own alerts.&lt;/p&gt;

&lt;p&gt;Next week is a bonus article outside the core series: production cost realities on the Consumption plan, and the signals that tell you it's time to move to Flex Consumption or Premium. If you've ever wondered why your monthly bill looked nothing like the pricing calculator, that one is for you.&lt;/p&gt;

&lt;p&gt;When you first wired up monitoring on a production function app, which alert did you set up first: failure rate or latency?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Azure Functions for .NET Developers: Series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: &lt;a href="https://dev.to/martin_oehlert/why-azure-functions-serverless-for-net-developers-707"&gt;Why Azure Functions? Serverless for .NET Developers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 2: &lt;a href="https://dev.to/martin_oehlert/your-first-azure-function-http-triggers-step-by-step-ib8"&gt;Your First Azure Function: HTTP Triggers Step-by-Step&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 3: &lt;a href="https://dev.to/martin_oehlert/beyond-http-timer-queue-and-blob-triggers-5aj5"&gt;Beyond HTTP: Timer, Queue, and Blob Triggers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 4: &lt;a href="https://dev.to/martin_oehlert/local-development-setup-tools-debugging-and-hot-reload-2925"&gt;Local Development Setup: Tools, Debugging, and Hot Reload&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 5: &lt;a href="https://dev.to/martin_oehlert/understanding-the-isolated-worker-model-5gd4"&gt;Understanding the Isolated Worker Model&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 6: &lt;a href="https://dev.to/martin_oehlert/configuration-done-right-settings-secrets-and-key-vault-3n7h"&gt;Configuration Done Right: Settings, Secrets, and Key Vault&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 7: &lt;a href="https://dev.to/martin_oehlert/testing-azure-functions-unit-integration-and-local-1cml"&gt;Testing Azure Functions: Unit, Integration, and Local&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 8: &lt;a href="https://dev.to/martin_oehlert/deploying-to-azure-cicd-with-github-actions-141m"&gt;Deploying to Azure: CI/CD with GitHub Actions&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 9: Azure Functions Observability: From Blind Spots to Production Clarity (this article)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Bonus: &lt;a href="https://dev.to/martin_oehlert/production-realities-when-azure-functions-stops-being-serverless-p2g"&gt;Production Realities: When Serverless Stops Being Serverless&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>dotnet</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Deploying to Azure: CI/CD with GitHub Actions</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 27 Mar 2026 06:36:06 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/deploying-to-azure-cicd-with-github-actions-141m</link>
      <guid>https://dev.to/martin_oehlert/deploying-to-azure-cicd-with-github-actions-141m</guid>
      <description>&lt;h2&gt;
  
  
  Introduction: from local to production
&lt;/h2&gt;

&lt;p&gt;Local tooling hides four things you have to own in production: packaging, authentication, configuration injection, and rollback. &lt;code&gt;func start&lt;/code&gt; handles all of them silently; a CI/CD pipeline does not, and the decisions you make about each one compound quickly.&lt;/p&gt;

&lt;p&gt;The gap is easy to miss. Your local environment reads from &lt;code&gt;local.settings.json&lt;/code&gt;, authenticates with your personal identity, and recovers from bad deploys by letting you just restart. Azure does none of that for you. You need a packaging step, a way to authenticate from a pipeline without storing secrets, a strategy for injecting environment-specific configuration, and some mechanism for rolling back when a deploy breaks something.&lt;/p&gt;

&lt;p&gt;This article covers two stages of that journey. First, manual deployment using the Azure CLI and the Functions Core Tools: useful for quick validation and understanding what the automated pipeline will do under the hood. Then a GitHub Actions workflow with two jobs, OIDC authentication (no stored credentials in your repository), deployment slots for zero-downtime releases, and configuration management that keeps secrets out of your pipeline definition entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manual deployment options
&lt;/h2&gt;

&lt;p&gt;Before wiring up a full CI/CD pipeline, understand what actually happens when code reaches Azure. Manual deployment gives you that visibility, and it remains useful long after you've automated everything: for one-off hotfixes, for validating a packaging issue, or for deploying to a scratch environment without spinning up a workflow run.&lt;/p&gt;

&lt;h3&gt;
  
  
  func azure functionapp publish
&lt;/h3&gt;

&lt;p&gt;The Core Tools command is the closest thing to a one-stop deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;func azure functionapp publish &amp;lt;APP_NAME&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, it runs &lt;code&gt;dotnet build --output bin/publish&lt;/code&gt;, creates a &lt;code&gt;.zip&lt;/code&gt; archive (filtered by your &lt;code&gt;.funcignore&lt;/code&gt;), uploads the archive via the Kudu ZipDeploy API (or One Deploy for Flex Consumption plans), and then syncs triggers and restarts the host. By default it also sets &lt;code&gt;WEBSITE_RUN_FROM_PACKAGE=1&lt;/code&gt; on the app, covered in the next subsection.&lt;/p&gt;

&lt;p&gt;Flags you'll reach for regularly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Skip the local build — useful when you've already built in CI&lt;/span&gt;
func azure functionapp publish &amp;lt;APP_NAME&amp;gt; &lt;span class="nt"&gt;--no-build&lt;/span&gt;

&lt;span class="c"&gt;# Deploy to a staging slot instead of production&lt;/span&gt;
func azure functionapp publish &amp;lt;APP_NAME&amp;gt; &lt;span class="nt"&gt;--slot&lt;/span&gt; staging

&lt;span class="c"&gt;# Push local.settings.json values to app settings (prompts for confirmation)&lt;/span&gt;
func azure functionapp publish &amp;lt;APP_NAME&amp;gt; &lt;span class="nt"&gt;--publish-local-settings&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt;

&lt;span class="c"&gt;# Verify what files will be included before committing to a deploy&lt;/span&gt;
func azure functionapp publish &amp;lt;APP_NAME&amp;gt; &lt;span class="nt"&gt;--list-included-files&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;--list-included-files&lt;/code&gt; at least once per project. If your archive includes &lt;code&gt;bin/&lt;/code&gt; debug artifacts, test assemblies, or secrets you meant to &lt;code&gt;.funcignore&lt;/code&gt;, you want to catch that before it's sitting on a production host.&lt;/p&gt;

&lt;p&gt;A minimal &lt;code&gt;.funcignore&lt;/code&gt; for a .NET project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*.csproj
*.sln
.git/
.vscode/
local.settings.json
test/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;local.settings.json&lt;/code&gt; is the most important exclusion: it often contains connection strings and keys meant for local development only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Azure CLI: two commands, two APIs
&lt;/h3&gt;

&lt;p&gt;The Azure CLI gives you two distinct options, and picking the wrong one for your plan type will fail silently or throw a confusing error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Kudu ZipDeploy — works for Consumption, Premium, and Dedicated plans&lt;/span&gt;
az functionapp deployment &lt;span class="nb"&gt;source &lt;/span&gt;config-zip &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-g&lt;/span&gt; &amp;lt;RESOURCE_GROUP&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;APP_NAME&amp;gt; &lt;span class="nt"&gt;--src&lt;/span&gt; ./publish.zip

&lt;span class="c"&gt;# One Deploy API — required for Flex Consumption, also valid elsewhere&lt;/span&gt;
az functionapp deploy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-g&lt;/span&gt; &amp;lt;RESOURCE_GROUP&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;APP_NAME&amp;gt; &lt;span class="nt"&gt;--src-path&lt;/span&gt; ./publish.zip &lt;span class="nt"&gt;--type&lt;/span&gt; zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The older &lt;code&gt;config-zip&lt;/code&gt; command talks directly to Kudu and does no building; you're responsible for providing a publish-ready zip. It does not support &lt;strong&gt;Flex Consumption&lt;/strong&gt;, the newer serverless plan that bypasses Kudu entirely. If you're on Flex Consumption, &lt;code&gt;az functionapp deploy&lt;/code&gt; is the only CLI path that works. It also gives you &lt;code&gt;--clean&lt;/code&gt; to remove files not in the archive and &lt;code&gt;--async&lt;/code&gt; to return immediately without polling for completion.&lt;/p&gt;

&lt;p&gt;A rule of thumb: if you're writing a deploy script that needs to work across plan types, use &lt;code&gt;az functionapp deploy&lt;/code&gt;. If you're on a legacy plan and &lt;code&gt;config-zip&lt;/code&gt; already exists in your runbooks, it's fine to leave it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run-From-Package and why it matters
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;WEBSITE_RUN_FROM_PACKAGE=1&lt;/code&gt; is set, Azure mounts your zip archive as a read-only filesystem at &lt;code&gt;wwwroot&lt;/code&gt; rather than extracting files into it. This is the default behavior when you publish with Core Tools, and it has real production benefits: deployment is &lt;strong&gt;atomic&lt;/strong&gt; (the old package stays mounted until the new one is ready), file-copy locking errors disappear, and cold start times improve because the runtime reads directly from the zip.&lt;/p&gt;

&lt;p&gt;The constraints: &lt;code&gt;wwwroot&lt;/code&gt; becomes read-only (portal-based editing no longer works), the archive has a 1 GB limit, and you should not set this value on Flex Consumption plans, which manage packages differently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which method to use
&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%2F7lw0jrcxz5vedkpmv3s6.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%2F7lw0jrcxz5vedkpmv3s6.png" alt="Which method to use: func publish for development, config-zip for legacy scripts, az functionapp deploy for all plans" width="800" height="206"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For anything beyond a one-off fix or an afternoon prototype, these manual commands are the foundation you'll extract into a pipeline. Knowing what each one does makes the GitHub Actions steps in the next section easier to reason about when something goes wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Actions workflow setup
&lt;/h2&gt;

&lt;p&gt;The pieces fit together like this:&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%2F22buyjo6967rdfy4zdab.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%2F22buyjo6967rdfy4zdab.png" alt="Deployment pipeline: Push to main, Build Job, Deploy Job with OIDC Login, Deploy to Staging Slot, Swap to Production" width="800" height="91"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The build job produces a single artifact. The deploy job authenticates via OIDC, pushes to a staging slot, and swaps it into production.&lt;/p&gt;

&lt;p&gt;The complete workflow is below. Read through it first; the walkthrough after explains the decisions behind each piece.&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy Azure Functions (.NET 10)&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&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="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;10.0.x'&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dotnet restore --locked-mode&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;dotnet publish src/MyFunctionApp&lt;/span&gt;
          &lt;span class="s"&gt;--configuration Release&lt;/span&gt;
          &lt;span class="s"&gt;--output ./output&lt;/span&gt;
          &lt;span class="s"&gt;--runtime linux-x64&lt;/span&gt;
          &lt;span class="s"&gt;--self-contained true&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;function-app&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./output&lt;/span&gt;
          &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;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="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;function-app&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./output&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azure/login@v2&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;client-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_CLIENT_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;tenant-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_TENANT_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;subscription-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_SUBSCRIPTION_ID }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Azure/functions-action@v1&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;app-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.FUNCTION_APP_NAME }}&lt;/span&gt;
          &lt;span class="na"&gt;slot-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;
          &lt;span class="na"&gt;package&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./output&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;Swap staging to production&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/cli@v2&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;inlineScript&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;az functionapp deployment slot swap \&lt;/span&gt;
              &lt;span class="s"&gt;--name ${{ vars.FUNCTION_APP_NAME }} \&lt;/span&gt;
              &lt;span class="s"&gt;--resource-group ${{ vars.RESOURCE_GROUP }} \&lt;/span&gt;
              &lt;span class="s"&gt;--slot staging \&lt;/span&gt;
              &lt;span class="s"&gt;--target-slot production&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same function app from Parts 1 through 7. The complete source is in the &lt;a href="https://github.com/MO2k4/azure-functions-samples" rel="noopener noreferrer"&gt;azure-functions-samples&lt;/a&gt; repository. Every push to &lt;code&gt;main&lt;/code&gt; builds it, deploys to a staging slot, and swaps to production. No secrets stored, no manual steps, and a rollback is one swap away.&lt;/p&gt;

&lt;p&gt;If your plan doesn't support slots (Consumption with only one slot available, or Flex Consumption), remove the &lt;code&gt;slot-name&lt;/code&gt; parameter and the swap step. The &lt;code&gt;functions-action&lt;/code&gt; will deploy directly to production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why two jobs instead of one
&lt;/h3&gt;

&lt;p&gt;The split between &lt;code&gt;build&lt;/code&gt; and &lt;code&gt;deploy&lt;/code&gt; exists for two reasons.&lt;/p&gt;

&lt;p&gt;First, the artifact produced by &lt;code&gt;build&lt;/code&gt; is reusable. If you add a staging environment later, the deploy job can run twice against the same artifact without rebuilding. Build once, deploy to as many environments as you need.&lt;/p&gt;

&lt;p&gt;Second, the &lt;strong&gt;&lt;code&gt;id-token: write&lt;/code&gt; permission&lt;/strong&gt; required for OIDC authentication (covered in the next section) is scoped to the &lt;code&gt;deploy&lt;/code&gt; job only. If you set it at the workflow level, every job gets that elevated permission. Keeping it on the deploy job limits the blast radius if something goes wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  The build job
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;actions/checkout@v4&lt;/code&gt; pulls your code. &lt;code&gt;actions/setup-dotnet@v4&lt;/code&gt; installs the SDK, and the &lt;code&gt;cache: true&lt;/code&gt; option tells it to cache the NuGet package cache between runs.&lt;/p&gt;

&lt;p&gt;That cache only works if your project has a &lt;strong&gt;lock file&lt;/strong&gt;. Add this to 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;RestorePackagesWithLockFile&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/RestorePackagesWithLockFile&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then commit the generated &lt;code&gt;packages.lock.json&lt;/code&gt;. Without it, &lt;code&gt;cache: true&lt;/code&gt; has nothing to hash (so every run misses the cache), and &lt;code&gt;--locked-mode&lt;/code&gt; silently regenerates a new lock file instead of validating against a committed one. With both in place, clean builds skip the network entirely for packages that haven't changed.&lt;/p&gt;

&lt;p&gt;The publish step is where .NET 10 requires extra care:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet publish src/MyFunctionApp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--configuration&lt;/span&gt; Release &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; ./output &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--runtime&lt;/span&gt; linux-x64 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--self-contained&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--self-contained true&lt;/code&gt; is required for .NET 10. The Azure Functions v4 host runs on .NET 8. If you publish a &lt;strong&gt;framework-dependent&lt;/strong&gt; app targeting .NET 10, the host cannot find the .NET 10 runtime and the deployment fails with exit code 150 (&lt;code&gt;0x96&lt;/code&gt;). A self-contained publish bundles the runtime with your app, so the host's .NET version becomes irrelevant.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;actions/upload-artifact@v4&lt;/code&gt; takes the &lt;code&gt;./output&lt;/code&gt; folder and makes it available to downstream jobs. The &lt;code&gt;name&lt;/code&gt; value (&lt;code&gt;function-app&lt;/code&gt;) is how the deploy job will refer to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The deploy job
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;needs: build&lt;/code&gt; means this job waits for the build to succeed before starting. &lt;code&gt;environment: production&lt;/code&gt; ties the job to a GitHub environment, which lets you add required reviewers or protection rules before any deployment proceeds.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;actions/download-artifact@v4&lt;/code&gt; retrieves the artifact by the same name used during upload and places it in &lt;code&gt;./output&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;azure/login@v2&lt;/code&gt; handles authentication using OIDC; the specifics of how to configure this are in the next section. This step must come before &lt;code&gt;functions-action&lt;/code&gt;, and the three secrets (&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;) must be set in your repository or environment settings.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Azure/functions-action@v1&lt;/code&gt; does the actual deployment. Two parameters are required: &lt;code&gt;app-name&lt;/code&gt; (the name of your Function App in Azure) and &lt;code&gt;package&lt;/code&gt; (the path to your artifact). An optional &lt;code&gt;slot-name&lt;/code&gt; parameter targets a deployment slot if you are using them.&lt;/p&gt;

&lt;p&gt;The deployment method the action uses depends on your hosting plan. Flex Consumption plans use &lt;strong&gt;One Deploy&lt;/strong&gt;; all other plans use &lt;strong&gt;Zip Deploy&lt;/strong&gt;. The action picks this automatically based on your app's plan type, so you do not need to configure it explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  OIDC authentication (no stored secrets)
&lt;/h2&gt;

&lt;p&gt;The workflow above uses three secrets: &lt;code&gt;AZURE_CLIENT_ID&lt;/code&gt;, &lt;code&gt;AZURE_TENANT_ID&lt;/code&gt;, and &lt;code&gt;AZURE_SUBSCRIPTION_ID&lt;/code&gt;. None of them are actual credentials. That's the point of OIDC.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not publish profiles or service principal secrets?
&lt;/h3&gt;

&lt;p&gt;Publish profiles are XML files containing deployment credentials baked into the Function App. They work, but they create problems at scale: they can't be scoped to a branch or environment, they don't expire on a schedule, and if one leaks, anyone with the file can deploy to your app until you manually reset it.&lt;/p&gt;

&lt;p&gt;Service principal secrets are better (they support expiration and RBAC scoping), but you still have a secret stored in GitHub that needs rotating every 6-24 months. Miss a rotation and your pipeline breaks silently on the next deploy.&lt;/p&gt;

&lt;p&gt;OIDC eliminates stored credentials entirely. GitHub mints a short-lived token for each workflow run, Azure validates that token against a federated credential you configure once, and nothing secret ever sits in your repository settings.&lt;/p&gt;

&lt;h3&gt;
  
  
  How it works
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Your workflow requests an OIDC token from GitHub's token service&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;azure/login&lt;/code&gt; action sends that token to Microsoft Entra ID&lt;/li&gt;
&lt;li&gt;Entra validates the token's issuer (&lt;code&gt;token.actions.githubusercontent.com&lt;/code&gt;), audience, and subject claim (which encodes the repo, branch, and environment)&lt;/li&gt;
&lt;li&gt;If the claims match your federated credential configuration, Entra issues an Azure access token&lt;/li&gt;
&lt;li&gt;The access token is used for the deployment, then expires&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The subject claim is what makes this granular. You can restrict a credential to only work from a specific environment (&lt;code&gt;repo:your-org/your-repo:environment:production&lt;/code&gt;), a specific branch, or even pull requests. A token minted from a feature branch won't match a credential scoped to the &lt;code&gt;production&lt;/code&gt; environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup steps
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Create an Entra app registration with a service principal:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az ad app create &lt;span class="nt"&gt;--display-name&lt;/span&gt; &lt;span class="s2"&gt;"github-deploy-my-func-app"&lt;/span&gt;
az ad sp create &lt;span class="nt"&gt;--id&lt;/span&gt; &amp;lt;APP_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Assign the &lt;code&gt;Website Contributor&lt;/code&gt; role&lt;/strong&gt; scoped to the resource group containing your Function App:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az role assignment create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assignee&lt;/span&gt; &amp;lt;APP_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt; &lt;span class="s2"&gt;"Website Contributor"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scope&lt;/span&gt; /subscriptions/&amp;lt;SUB_ID&amp;gt;/resourceGroups/&amp;lt;RG_NAME&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Website Contributor&lt;/code&gt; is enough for deploying code. &lt;code&gt;Contributor&lt;/code&gt; works too but grants more access than the pipeline needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Configure a federated identity credential:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"github-actions-production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issuer"&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://token.actions.githubusercontent.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"repo:your-org/your-repo:environment:production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"audiences"&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="s2"&gt;"api://AzureADTokenExchange"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az ad app federated-credential create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--id&lt;/span&gt; &amp;lt;APP_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--parameters&lt;/span&gt; @credential.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;subject&lt;/code&gt; field must match exactly. If your deploy job uses &lt;code&gt;environment: production&lt;/code&gt;, the subject must end with &lt;code&gt;:environment:production&lt;/code&gt;. If you deploy from a branch without an environment, use &lt;code&gt;:ref:refs/heads/main&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Store the IDs as GitHub environment secrets:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Go to your repository Settings &amp;gt; Environments &amp;gt; production &amp;gt; Environment secrets, and add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;AZURE_CLIENT_ID&lt;/code&gt;: the Application (client) ID from your app registration&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AZURE_TENANT_ID&lt;/code&gt;: your Entra tenant ID&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AZURE_SUBSCRIPTION_ID&lt;/code&gt;: the subscription containing your Function App&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are identifiers, not credentials. Even if someone reads them, they can't authenticate without a valid OIDC token from your specific repository and environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workflow permissions
&lt;/h3&gt;

&lt;p&gt;The deploy job needs &lt;code&gt;id-token: write&lt;/code&gt; to mint the OIDC token:&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;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set this on the deploy job only, not at the workflow level. The build job doesn't need token-minting permissions.&lt;/p&gt;

&lt;h3&gt;
  
  
  One gotcha
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;Azure/functions-action&lt;/code&gt; supports two authentication methods: &lt;code&gt;publish-profile&lt;/code&gt; and the &lt;code&gt;azure/login&lt;/code&gt; action. They are &lt;strong&gt;mutually exclusive&lt;/strong&gt;. If you pass a &lt;code&gt;publish-profile&lt;/code&gt; parameter while also using &lt;code&gt;azure/login&lt;/code&gt;, the action uses the publish profile and ignores your OIDC session. Remove the &lt;code&gt;publish-profile&lt;/code&gt; parameter entirely when switching to OIDC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment slots and zero-downtime releases
&lt;/h2&gt;

&lt;p&gt;Deploying directly to production means every release has a moment where either the old code or the new code is partially running. Deployment slots give you a staging URL to validate before any production traffic sees the new version, and an instant rollback if something goes wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  What each plan supports
&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%2Fcae24fmuypp8l6htr2vs.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%2Fcae24fmuypp8l6htr2vs.png" alt="Deployment slots by plan: Consumption 2, Premium 3, Dedicated 1-20, Flex Consumption not supported" width="466" height="214"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're on Flex Consumption, skip to the rolling updates section below.&lt;/p&gt;

&lt;h3&gt;
  
  
  The blue-green pattern
&lt;/h3&gt;

&lt;p&gt;Deploy to a staging slot, verify it works, then swap staging into production.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deploy to staging&lt;/strong&gt;: your CI/CD pipeline targets the &lt;code&gt;staging&lt;/code&gt; slot instead of production&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate&lt;/strong&gt;: hit the staging URL (&lt;code&gt;your-func-app-staging.azurewebsites.net&lt;/code&gt;) with smoke tests or manual checks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swap&lt;/strong&gt;: Azure switches the routing so staging serves production traffic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback if needed&lt;/strong&gt;: swap again to revert (the old production code is now in the staging slot)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The swap itself takes seconds. Your users see either the old version or the new version, never a half-deployed state.&lt;/p&gt;

&lt;h3&gt;
  
  
  What swaps and what stays
&lt;/h3&gt;

&lt;p&gt;This trips people up. During a swap, &lt;strong&gt;code and most settings travel together&lt;/strong&gt; from staging to production. But some things are pinned to the slot:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Travels with code (gets swapped):&lt;/strong&gt; general app settings (unless marked sticky), connection strings (unless marked sticky), handler mappings, public certificates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stays with the slot:&lt;/strong&gt; publishing endpoints, custom domains, TLS/SSL certificates, scale settings, IP restrictions, Always On, &lt;code&gt;FUNCTIONS_EXTENSION_VERSION&lt;/code&gt; (sticky by default).&lt;/p&gt;

&lt;h3&gt;
  
  
  Sticky settings that cause problems
&lt;/h3&gt;

&lt;p&gt;Two settings deserve special attention:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;FUNCTIONS_EXTENSION_VERSION&lt;/code&gt;&lt;/strong&gt; is sticky by default. If your staging slot runs &lt;code&gt;~4&lt;/code&gt; and production also runs &lt;code&gt;~4&lt;/code&gt;, this is invisible. But if you ever need to change the version, the stickiness means the setting won't swap with the code. To make it travel with the swap, set &lt;code&gt;WEBSITE_OVERRIDE_STICKY_EXTENSION_VERSIONS=0&lt;/code&gt; on &lt;strong&gt;all&lt;/strong&gt; slots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;WEBSITE_CONTENTSHARE&lt;/code&gt;&lt;/strong&gt; is auto-generated per slot and should never be set manually. Each slot needs its own content share to avoid file locking conflicts. If you see deployment failures mentioning "cannot access file," check whether slots are sharing this value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deploy-to-slot and swap in GitHub Actions
&lt;/h3&gt;

&lt;p&gt;Add &lt;code&gt;slot-name&lt;/code&gt; to the deploy step, then swap using the Azure CLI:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Azure/functions-action@v1&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;app-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;my-func-app'&lt;/span&gt;
    &lt;span class="na"&gt;slot-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;
    &lt;span class="na"&gt;package&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./output&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;Swap staging to production&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/cli@v2&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;inlineScript&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;az functionapp deployment slot swap \&lt;/span&gt;
        &lt;span class="s"&gt;--name my-func-app \&lt;/span&gt;
        &lt;span class="s"&gt;--resource-group my-rg \&lt;/span&gt;
        &lt;span class="s"&gt;--slot staging \&lt;/span&gt;
        &lt;span class="s"&gt;--target-slot production&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Swap gotchas
&lt;/h3&gt;

&lt;p&gt;Watch for these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Running functions are terminated&lt;/strong&gt; during a swap. There is no graceful drain. If you have long-running executions, they will be killed. For timer or queue triggers, the runtime will pick up incomplete work after the swap, but HTTP requests in flight will fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warm-up matters.&lt;/strong&gt; After a swap, the new production instances need to initialize. Set &lt;code&gt;WEBSITE_SWAP_WARMUP_PING_PATH&lt;/code&gt; to an endpoint (like a health check) that forces initialization before traffic arrives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep app names under 32 characters.&lt;/strong&gt; Longer names can cause host ID collisions between slots, leading to unexpected behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Flex Consumption alternative: rolling updates
&lt;/h3&gt;

&lt;p&gt;Flex Consumption doesn't support slots, but it offers rolling updates as an alternative. With &lt;code&gt;siteUpdateStrategy.type&lt;/code&gt; set to &lt;code&gt;RollingUpdate&lt;/code&gt;, Azure replaces instances in batches rather than all at once, giving in-progress executions a 60-minute grace period to complete.&lt;/p&gt;

&lt;p&gt;The trade-off: there's no separate staging URL for validation, no way to split traffic between versions, and rollback means redeploying the previous version rather than an instant swap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment configuration in pipelines
&lt;/h2&gt;

&lt;p&gt;A deployment pipeline needs to put the right configuration in the right environment without leaking secrets into workflow files. GitHub Environments, the secrets hierarchy, and Key Vault references each handle a piece of this.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub Environments
&lt;/h3&gt;

&lt;p&gt;Environments are configured under your repository's Settings &amp;gt; Environments. Each environment can have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Required reviewers&lt;/strong&gt; (up to 6 people who must approve before the deploy job runs)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait timers&lt;/strong&gt; (a delay before deployment proceeds, useful for change windows)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment branches&lt;/strong&gt; (restrict which branches can target this environment)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the workflow, &lt;code&gt;environment: production&lt;/code&gt; on a job ties it to that environment's rules. The job will pause and wait for approval if reviewers are configured.&lt;/p&gt;

&lt;h3&gt;
  
  
  Secrets hierarchy
&lt;/h3&gt;

&lt;p&gt;GitHub secrets exist at three levels:&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%2Fnihqze1g59dtnn7jg4b4.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%2Fnihqze1g59dtnn7jg4b4.png" alt="GitHub secrets hierarchy: Organization, Repository, and Environment levels with scope and examples" width="752" height="174"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When the same secret name exists at multiple levels, &lt;strong&gt;environment wins over repository, which wins over organization&lt;/strong&gt;. This means you can set &lt;code&gt;AZURE_CLIENT_ID&lt;/code&gt; at the environment level with different values for &lt;code&gt;development&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, and &lt;code&gt;production&lt;/code&gt;, each pointing to a different service principal scoped to its own resource group.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting app configuration during deployment
&lt;/h3&gt;

&lt;p&gt;Your Function App needs configuration values beyond what's in the code. The most direct approach is the Azure CLI:&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="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;Configure app settings&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/cli@v2&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;inlineScript&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;az functionapp config appsettings set \&lt;/span&gt;
        &lt;span class="s"&gt;--name ${{ vars.FUNCTION_APP_NAME }} \&lt;/span&gt;
        &lt;span class="s"&gt;--resource-group ${{ vars.RESOURCE_GROUP }} \&lt;/span&gt;
        &lt;span class="s"&gt;--settings \&lt;/span&gt;
          &lt;span class="s"&gt;"ServiceBus__Connection=${{ secrets.SERVICEBUS_CONNECTION }}" \&lt;/span&gt;
          &lt;span class="s"&gt;"FeatureFlags__NewCheckout=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;vars&lt;/code&gt; (GitHub Variables) for non-sensitive configuration and &lt;code&gt;secrets&lt;/code&gt; for anything you wouldn't put in a log file.&lt;/p&gt;

&lt;p&gt;One warning if you manage settings through Bicep or ARM templates instead: the ARM API &lt;strong&gt;replaces&lt;/strong&gt; all app settings on each deployment. If your template omits a setting that exists on the app, that setting gets deleted. The CLI's &lt;code&gt;appsettings set&lt;/code&gt; command merges instead, which is safer for incremental updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-environment workflow
&lt;/h3&gt;

&lt;p&gt;The build-once-deploy-many pattern chains environments with approval gates:&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;jobs&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="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="c1"&gt;# ... build steps from earlier ...&lt;/span&gt;

  &lt;span class="na"&gt;deploy-dev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;development&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# download artifact, azure/login, functions-action&lt;/span&gt;
      &lt;span class="c1"&gt;# (same structure, different secrets per environment)&lt;/span&gt;

  &lt;span class="na"&gt;deploy-staging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-dev&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# deploy to staging slot, run smoke tests&lt;/span&gt;

  &lt;span class="na"&gt;deploy-production&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-staging&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;  &lt;span class="c1"&gt;# approval gate triggers here&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# swap staging to production&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same artifact flows through all three environments. The only things that change are the secrets (different &lt;code&gt;AZURE_CLIENT_ID&lt;/code&gt; per environment, each scoped to its own resource group) and the deployment target.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Vault integration
&lt;/h3&gt;

&lt;p&gt;This ties back to &lt;a href="https://dev.to/martin_oehlert/configuration-done-right-settings-secrets-and-key-vault-3n7h"&gt;Part 6 (Configuration Done Right)&lt;/a&gt;. Instead of passing secret values through your pipeline, store them in Key Vault and reference them in app settings:&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;ServiceBus__Connection&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;@Microsoft.KeyVault(VaultName=my-kv;SecretName=servicebus-conn)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your pipeline sets the &lt;strong&gt;reference&lt;/strong&gt;, not the secret value. The Function App's managed identity resolves the actual value at runtime using the &lt;code&gt;Key Vault Secrets User&lt;/code&gt; role. The pipeline never sees the secret, and rotating it in Key Vault takes effect without redeployment.&lt;/p&gt;

&lt;p&gt;If you use deployment slots, mark Key Vault references as slot settings when different environments need different secrets (e.g., staging points to a staging Key Vault, production to a production Key Vault).&lt;/p&gt;

&lt;h3&gt;
  
  
  What goes where
&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%2Ftbxbgmxgtnorru9lrhhr.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%2Ftbxbgmxgtnorru9lrhhr.png" alt="What goes where: GitHub Secrets for IDs, Key Vault for connection strings and keys, avoid secrets in YAML or Bicep" width="766" height="214"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Eight articles, one function app, and a pipeline that deploys itself. If something in your own setup doesn't match what's here, the series navigation links every piece: from the first HTTP trigger through testing to this deployment workflow.&lt;/p&gt;

&lt;p&gt;Do you deploy straight to production or use a staging slot? What made you choose one over the other?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Azure Functions for .NET Developers: Series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: &lt;a href="https://dev.to/martin_oehlert/why-azure-functions-serverless-for-net-developers-707"&gt;Why Azure Functions? Serverless for .NET Developers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 2: &lt;a href="https://dev.to/martin_oehlert/your-first-azure-function-http-triggers-step-by-step-ib8"&gt;Your First Azure Function: HTTP Triggers Step-by-Step&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 3: &lt;a href="https://dev.to/martin_oehlert/beyond-http-timer-queue-and-blob-triggers-5aj5"&gt;Beyond HTTP: Timer, Queue, and Blob Triggers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 4: &lt;a href="https://dev.to/martin_oehlert/local-development-setup-tools-debugging-and-hot-reload-2925"&gt;Local Development Setup: Tools, Debugging, and Hot Reload&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 5: &lt;a href="https://dev.to/martin_oehlert/understanding-the-isolated-worker-model-5gd4"&gt;Understanding the Isolated Worker Model&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 6: &lt;a href="https://dev.to/martin_oehlert/configuration-done-right-settings-secrets-and-key-vault-3n7h"&gt;Configuration Done Right: Settings, Secrets, and Key Vault&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 7: &lt;a href="https://dev.to/martin_oehlert/testing-azure-functions-unit-integration-and-local-1cml"&gt;Testing Azure Functions: Unit, Integration, and Local&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 8: Deploying to Azure: CI/CD with GitHub Actions (this article)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Part 9: &lt;a href="https://dev.to/martin_oehlert/azure-functions-observability-from-blind-spots-to-production-clarity-24j4"&gt;Azure Functions Observability: From Blind Spots to Production Clarity&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Bonus: &lt;a href="https://dev.to/martin_oehlert/production-realities-when-azure-functions-stops-being-serverless-p2g"&gt;Production Realities: When Serverless Stops Being Serverless&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

</description>
      <category>azure</category>
      <category>dotnet</category>
      <category>github</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Figuring out what actually needs a real Azure connection vs. what you can just test with a plain class… that’s where most testing headaches start. I wrote up how I handle it: unit tests, Testcontainers + Azurite, and full func start pipelines.</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Sat, 21 Mar 2026 06:16:23 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/figuring-out-what-actually-needs-a-real-azure-connection-vs-what-you-can-just-test-with-a-plain-548</link>
      <guid>https://dev.to/martin_oehlert/figuring-out-what-actually-needs-a-real-azure-connection-vs-what-you-can-just-test-with-a-plain-548</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/martin_oehlert/testing-azure-functions-unit-integration-and-local-1cml" class="crayons-story__hidden-navigation-link"&gt;Testing Azure Functions: Unit, Integration, and Local&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/martin_oehlert" class="crayons-avatar  crayons-avatar--l  "&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%2Fuser%2Fprofile_image%2F1661015%2Fd0bdf508-0244-49d8-8655-aea054d71b86.png" alt="martin_oehlert profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/martin_oehlert" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Martin Oehlert
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Martin Oehlert
                
              
              &lt;div id="story-author-preview-content-3375218" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/martin_oehlert" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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%2Fuser%2Fprofile_image%2F1661015%2Fd0bdf508-0244-49d8-8655-aea054d71b86.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Martin Oehlert&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/martin_oehlert/testing-azure-functions-unit-integration-and-local-1cml" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Mar 20&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/martin_oehlert/testing-azure-functions-unit-integration-and-local-1cml" id="article-link-3375218"&gt;
          Testing Azure Functions: Unit, Integration, and Local
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/azure"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;azure&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/azurefunctions"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;azurefunctions&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/serverless"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;serverless&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/dotnet"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;dotnet&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/martin_oehlert/testing-azure-functions-unit-integration-and-local-1cml" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt; reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/martin_oehlert/testing-azure-functions-unit-integration-and-local-1cml#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              &lt;span class="hidden s:inline"&gt;Add Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            15 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>serverless</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Testing Azure Functions: Unit, Integration, and Local</title>
      <dc:creator>Martin Oehlert</dc:creator>
      <pubDate>Fri, 20 Mar 2026 07:17:37 +0000</pubDate>
      <link>https://dev.to/martin_oehlert/testing-azure-functions-unit-integration-and-local-1cml</link>
      <guid>https://dev.to/martin_oehlert/testing-azure-functions-unit-integration-and-local-1cml</guid>
      <description>&lt;p&gt;Where do you draw the line between what needs a full Azure connection and what can be tested with a plain class instantiation? The isolated worker model makes the answer concrete: the function class is just wiring. Everything testable lives in a service class that knows nothing about Azure.&lt;/p&gt;

&lt;p&gt;Most testing pain comes from not drawing that line early enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design decision that makes testing possible
&lt;/h2&gt;

&lt;p&gt;Consider a function that does its own 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;class&lt;/span&gt; &lt;span class="nc"&gt;OrderFunction&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;OrderFunction&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;SqlConnection&lt;/span&gt; &lt;span class="n"&gt;db&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;"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;Anonymous&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;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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="s"&gt;"Quantity must be greater than zero"&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="s"&gt;"ORD-"&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="m"&gt;8&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;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"INSERT INTO Orders (OrderId, ProductId, Quantity) VALUES (@OrderId, @ProductId, @Quantity)"&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;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProductId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="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;"Created order {OrderId}"&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;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;$"/orders/&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="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProductId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To unit test this, you need a real &lt;code&gt;SqlConnection&lt;/code&gt;. That means a real database, which means either a running SQL Server, Testcontainers, or a brittle in-memory substitute. Every test becomes an infrastructure test, even for something as simple as verifying that a zero quantity returns 400.&lt;/p&gt;

&lt;p&gt;The fix is to move the logic into a service class, leaving the function with nothing to do except call the service and map the result to a response:&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;OrderFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IOrderService&lt;/span&gt; &lt;span class="n"&gt;orderService&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;"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;Anonymous&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;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;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;orderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateOrderAsync&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsSuccess&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="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;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;$"/orders/&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;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;result&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;The function class is now three lines of routing logic. &lt;code&gt;IOrderService&lt;/code&gt; is a plain interface with no Azure types, no infrastructure, nothing that requires a running host to instantiate.&lt;/p&gt;

&lt;p&gt;This gives you two separate test targets. The service holds the logic and gets fast, isolated unit tests with no framework setup. The function class holds the routing and gets a thin layer of tests that verify the HTTP response shapes. Each layer can be tested on its own terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unit testing the service layer
&lt;/h2&gt;

&lt;p&gt;The service has one dependency worth injecting for tests: &lt;code&gt;IOrderRepository&lt;/code&gt;. Here's the full service:&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;OrderService&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;OrderService&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;IOrderRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IOrderService&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;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CreateOrderAsync&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="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;request&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;lt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Quantity must be greater than zero"&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;OrderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ORD-"&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="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;ProductId&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;ProductId&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="n"&gt;request&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;repository&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;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;"Created order {OrderId} for {ProductId}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProductId&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;OrderResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;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;To test it, you need xUnit and NSubstitute. The &lt;code&gt;.csproj&lt;/code&gt; is minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Project&lt;/span&gt; &lt;span class="na"&gt;Sdk=&lt;/span&gt;&lt;span class="s"&gt;"Microsoft.NET.Sdk"&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;IsTestProject&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/IsTestProject&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- Test method names use underscores by convention (MethodName_Condition_Expected) --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;NoWarn&amp;gt;&lt;/span&gt;$(NoWarn);CA1707&lt;span class="nt"&gt;&amp;lt;/NoWarn&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;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.NET.Test.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;"NSubstitute"&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;"xunit"&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;"xunit.runner.visualstudio"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;PrivateAssets&amp;gt;&lt;/span&gt;all&lt;span class="nt"&gt;&amp;lt;/PrivateAssets&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;IncludeAssets&amp;gt;&lt;/span&gt;runtime; build; native; contentfiles; analyzers&lt;span class="nt"&gt;&amp;lt;/IncludeAssets&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/PackageReference&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;"../HttpTriggerDemo/HttpTriggerDemo.csproj"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/ItemGroup&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Project&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests themselves need no Azure infrastructure:&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;OrderServiceTests&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;IOrderRepository&lt;/span&gt; &lt;span class="n"&gt;_repository&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Substitute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IOrderRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;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;OrderService&lt;/span&gt; &lt;span class="n"&gt;_service&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;OrderServiceTests&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderService&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;OrderService&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="n"&gt;_repository&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;CreateOrderAsync_WithValidRequest_ReturnsSuccess&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CreateOrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WIDGET-42"&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="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;_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateOrderAsync&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;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;True&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsSuccess&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;NotNull&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;Order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WIDGET-42"&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;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProductId&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;3&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;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;CreateOrderAsync_WithValidRequest_SavesOrderToRepository&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CreateOrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WIDGET-42"&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;await&lt;/span&gt; &lt;span class="n"&gt;_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateOrderAsync&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Received&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="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Arg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProductId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"WIDGET-42"&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&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="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Theory&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;InlineData&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="nf"&gt;InlineData&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;InlineData&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="m"&gt;100&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;CreateOrderAsync_WithInvalidQuantity_ReturnsFailure&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="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CreateOrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WIDGET-42"&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="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;_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateOrderAsync&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;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;False&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsSuccess&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;NotNull&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;Error&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;Theory&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;InlineData&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="nf"&gt;InlineData&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="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;CreateOrderAsync_WithInvalidQuantity_DoesNotCallRepository&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="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CreateOrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WIDGET-42"&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateOrderAsync&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DidNotReceive&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;Arg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Any&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="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;NullLogger&amp;lt;T&amp;gt;.Instance&lt;/code&gt; is the right choice for service tests. You are testing behavior, not logging output. Using &lt;code&gt;Substitute.For&amp;lt;ILogger&amp;lt;T&amp;gt;&amp;gt;()&lt;/code&gt; to verify that specific log messages were emitted is a fragile approach: log messages are implementation details that change often and aren't part of the service contract. Save NSubstitute for dependencies whose behavior actually matters to the test outcome, like &lt;code&gt;IOrderRepository&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[Theory]&lt;/code&gt; + &lt;code&gt;[InlineData]&lt;/code&gt; handles the validation branches without duplicating test body. Each &lt;code&gt;InlineData&lt;/code&gt; value runs as a separate test in the output, so you get clear signal on exactly which inputs fail. The two &lt;code&gt;[Theory]&lt;/code&gt; blocks above run 3 + 2 = 5 test cases from a handful of lines.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Received()&lt;/code&gt; and &lt;code&gt;DidNotReceive()&lt;/code&gt; are NSubstitute's call-count assertions. The second &lt;code&gt;[Fact]&lt;/code&gt; verifies the repository was called with the right data; the second &lt;code&gt;[Theory]&lt;/code&gt; verifies it was never called when validation fails. Together they cover both the happy path and the guard clause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unit testing the function class
&lt;/h2&gt;

&lt;p&gt;When you use &lt;code&gt;ConfigureFunctionsWebApplication()&lt;/code&gt; (the ASP.NET Core integration mode), the function's &lt;code&gt;HttpRequest&lt;/code&gt; is a standard ASP.NET Core &lt;code&gt;HttpRequest&lt;/code&gt;. That means you can construct a &lt;code&gt;DefaultHttpContext&lt;/code&gt; in tests and pass &lt;code&gt;context.Request&lt;/code&gt; directly to the function method, with no Functions runtime involved:&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;OrderFunctionTests&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;IOrderService&lt;/span&gt; &lt;span class="n"&gt;_orderService&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Substitute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IOrderService&lt;/span&gt;&lt;span class="p"&gt;&amp;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;OrderFunction&lt;/span&gt; &lt;span class="n"&gt;_function&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;OrderFunctionTests&lt;/span&gt;&lt;span class="p"&gt;()&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="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_orderService&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;CreateOrder_WhenServiceSucceeds_Returns201Created&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CreateOrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WIDGET-42"&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ORD-ABCD1234"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"WIDGET-42"&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="n"&gt;_orderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateOrderAsync&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="nf"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;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;_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateOrder&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;DefaultHttpContext&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;request&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;created&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="n"&gt;IsType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CreatedResult&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/orders/ORD-ABCD1234"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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="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;CreateOrder_WhenServiceFails_Returns400BadRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CreateOrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WIDGET-42"&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="n"&gt;_orderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateOrderAsync&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Quantity must be greater than zero"&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;_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateOrder&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;DefaultHttpContext&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;request&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;bad&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="n"&gt;IsType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BadRequestObjectResult&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Quantity must be greater than zero"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bad&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what these tests do not cover: the &lt;code&gt;[HttpTrigger]&lt;/code&gt; attribute, binding resolution, middleware, or anything the Functions host owns. That's intentional. The function's responsibility is to map a service result to an HTTP response. Two tests cover both outcome branches. Anything beyond that is integration territory.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;[HttpTrigger]&lt;/code&gt; and &lt;code&gt;[FromBody]&lt;/code&gt; attributes are metadata for the runtime. They don't execute during a direct method call, so there's nothing to test or mock.&lt;/p&gt;

&lt;h3&gt;
  
  
  Timer triggers
&lt;/h3&gt;

&lt;p&gt;Timer functions follow the same pattern. &lt;code&gt;TimerInfo&lt;/code&gt; is a concrete class from the Functions SDK with settable properties, so you construct it 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CleanupFunctionTests&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;CleanupFunction&lt;/span&gt; &lt;span class="n"&gt;_function&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;NullLogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CleanupFunction&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="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;Run_WhenOnSchedule_CompletesWithoutError&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;timer&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;TimerInfo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;IsPastDue&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// No exception thrown = the function handled the timer correctly.&lt;/span&gt;
        &lt;span class="c1"&gt;// Timer functions have no return value — the observable outcome is&lt;/span&gt;
        &lt;span class="c1"&gt;// either successful completion or an exception.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Run_WhenPastDue_StillCompletes&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;timer&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;TimerInfo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;IsPastDue&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timer&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;Timer function tests are often this minimal. The function's behavior on &lt;code&gt;IsPastDue = true&lt;/code&gt; is to log a warning; there's no meaningful return value to assert on. What you're verifying is that the function reaches completion without throwing, and that the &lt;code&gt;IsPastDue&lt;/code&gt; branch doesn't break anything. If your cleanup function does real work (deleting records, archiving blobs), that work lives in a service and gets tested through the service tests, not through the function.&lt;/p&gt;




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

&lt;p&gt;Unit tests get you 80% of the way. They don't verify that DI registrations are correct, that your database schema matches your queries, or that a real &lt;code&gt;TableClient&lt;/code&gt; call actually persists what you think it does. That's two separate problems: the composition root, and the data layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verifying the composition root
&lt;/h3&gt;

&lt;p&gt;The first failure mode is silent: a service registration is missing, and &lt;code&gt;OrderFunction&lt;/code&gt;'s constructor throws at runtime while every unit test passes. A composition test catches this without Docker:&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;HostIntegrationTests&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IAsyncLifetime&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="n"&gt;IHost&lt;/span&gt; &lt;span class="n"&gt;_host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;!;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;InitializeAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_host&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;HostBuilder&lt;/span&gt;&lt;span class="p"&gt;()&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureServices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;services&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// WorkerHostedService opens a gRPC channel to the Functions host.&lt;/span&gt;
                &lt;span class="c1"&gt;// That host doesn't exist in tests — remove it or the build hangs.&lt;/span&gt;
                &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;worker&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;FirstOrDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
                    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ImplementationType&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"WorkerHostedService"&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;worker&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;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;worker&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;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;IOrderService_ResolvesFromDi&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;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IOrderService&lt;/span&gt;&lt;span class="p"&gt;&amp;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;NotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="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;DisposeAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StopAsync&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;WebApplicationFactory&amp;lt;Program&amp;gt;&lt;/code&gt; fails with Azure Functions isolated worker. The model uses gRPC for host-worker communication, and the factory hits a channel URI parsing error when no Functions host is running. The &lt;code&gt;HostBuilder&lt;/code&gt; approach mirrors &lt;code&gt;Program.cs&lt;/code&gt; exactly, with the gRPC listener stripped. This test doesn't call any function logic; it only verifies the container compiles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing the data layer
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;InMemoryOrderRepository&lt;/code&gt; lets unit tests run fast, but it tells you nothing about whether your actual persistence works. A production implementation using Azure Table Storage looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TableStorageOrderRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TableClient&lt;/span&gt; &lt;span class="n"&gt;tableClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IOrderRepository&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;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="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TableEntity&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;ProductId&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="p"&gt;{&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Quantity"&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;Quantity&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;tableClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddEntityAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="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 integration test spins up Azurite in Docker via Testcontainers:&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;TableStorageOrderRepositoryTests&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IAsyncLifetime&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;AzuriteContainer&lt;/span&gt; &lt;span class="n"&gt;_azurite&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;AzuriteBuilder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;InitializeAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_azurite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="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;SaveAsync_WithValidOrder_PersistsToTableStorage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TableClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_azurite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetConnectionString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="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;CreateIfNotExistsAsync&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;repository&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;TableStorageOrderRepository&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ORD-TEST01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"WIDGET-42"&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;await&lt;/span&gt; &lt;span class="n"&gt;repository&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetEntityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TableEntity&lt;/span&gt;&lt;span class="p"&gt;&amp;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;ProductId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;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;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Quantity"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;DisposeAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_azurite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DisposeAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add one package to the test project:&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;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"Testcontainers.Azurite"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each test run gets a fresh container. No ports to reserve, no cleanup between runs; Testcontainers handles port allocation for parallel CI execution automatically.&lt;/p&gt;

&lt;p&gt;The same pattern covers blob and queue operations. For relational databases, &lt;code&gt;Testcontainers.MsSql&lt;/code&gt; and &lt;code&gt;Testcontainers.PostgreSql&lt;/code&gt; provide the same lifecycle wrapper for SQL Server and Postgres.&lt;/p&gt;




&lt;h2&gt;
  
  
  Local E2E testing with &lt;code&gt;func start&lt;/code&gt; + Azurite
&lt;/h2&gt;

&lt;p&gt;Logic tests cover &lt;code&gt;OrderService&lt;/code&gt;. Repository tests cover &lt;code&gt;TableStorageOrderRepository&lt;/code&gt;. Neither covers what happens when the Functions host receives an HTTP request, routes it through middleware, deserializes the body, calls the function, and returns a response.&lt;/p&gt;

&lt;p&gt;For that, the host must be running. The approach is to start Azurite and &lt;code&gt;func start&lt;/code&gt; together in the test fixture:&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;FunctionsE2ETests&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IAsyncLifetime&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;AzuriteContainer&lt;/span&gt; &lt;span class="n"&gt;_azurite&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;AzuriteBuilder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="n"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_funcProcess&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;_client&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;InitializeAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_azurite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;_funcProcess&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Start&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;ProcessStartInfo&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;FileName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"func"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Arguments&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"start --port 7071"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;WorkingDirectory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetFullPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"../../../HttpTriggerDemo"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;EnvironmentVariables&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;"AzureWebJobsStorage"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_azurite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetConnectionString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"FUNCTIONS_WORKER_RUNTIME"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"dotnet-isolated"&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;RedirectStandardOutput&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;UseShellExecute&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;WaitForHostReady&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_funcProcess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;CreateOrder_WithValidRequest_Returns201&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;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"http://localhost:7071/api/orders"&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;CreateOrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WIDGET-42"&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="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="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;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;WaitForHostReady&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Process&lt;/span&gt; &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ready&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;TaskCompletionSource&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
        &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OutputDataReceived&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Host started"&lt;/span&gt;&lt;span class="p"&gt;)&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;ready&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrySetResult&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BeginOutputReadLine&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;ready&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;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;DisposeAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_funcProcess&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;Kill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entireProcessTree&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_azurite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DisposeAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Dispose&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;func&lt;/code&gt; must be on the PATH. CI pipelines need &lt;code&gt;npm install -g azure-functions-core-tools@4&lt;/code&gt; before these tests run: it's a test infrastructure dependency that bites if you assume it's there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Kill(entireProcessTree: true)&lt;/code&gt; matters on Windows. &lt;code&gt;func start&lt;/code&gt; spawns child processes; killing just the parent leaves orphaned processes holding port 7071, which causes every subsequent E2E test run in that session to hang on startup.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;WaitForHostReady&lt;/code&gt; polls stdout for "Host started". Startup takes 3-10 seconds depending on cold JIT and machine speed. Set the timeout conservatively: a flaky timeout is harder to debug than a slow test.&lt;/p&gt;

&lt;p&gt;Put these tests in a separate project with &lt;code&gt;[Trait("Category", "E2E")]&lt;/code&gt; and exclude them from the fast inner development loop. They're most useful in CI as a gate before deployment, not as daily feedback during development.&lt;/p&gt;




&lt;h2&gt;
  
  
  Testing an event-driven function
&lt;/h2&gt;

&lt;p&gt;HTTP triggers test cleanly: call the function directly, inspect the return value. Event Hub triggers are different. The function receives a batch of &lt;code&gt;EventData&lt;/code&gt;, deserializes each message, and delegates to a service; the trigger binding itself is provided by the runtime. That runtime can run locally.&lt;/p&gt;

&lt;p&gt;The scenario here is an IoT pipeline: devices publish sensor readings to an Event Hub, and a function consumes the batch, validates each reading, and writes to Cosmos DB.&lt;/p&gt;

&lt;h3&gt;
  
  
  The function
&lt;/h3&gt;

&lt;p&gt;The function stays thin. Deserialize the batch, call the service, nothing else:&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;SensorReadingFunction&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;SensorReadingFunction&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;ISensorProcessor&lt;/span&gt; &lt;span class="n"&gt;processor&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;SensorReadingFunction&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;EventHubTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sensor-readings"&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;"EventHubConnection"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="n"&gt;EventData&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;events&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;"Processing batch of {Count} events"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;events&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="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;eventData&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&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;reading&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;SensorReading&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;eventData&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;Span&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;reading&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;continue&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;processor&lt;/span&gt;&lt;span class="p"&gt;.&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;reading&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;EventData[]&lt;/code&gt; parameter receives the batch. The function doesn't know or care how many partitions the hub has, how messages were routed, or what retry policy applies: that's the runtime's job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unit testing the function
&lt;/h3&gt;

&lt;p&gt;Construct &lt;code&gt;EventData&lt;/code&gt; directly with a JSON body and call &lt;code&gt;Run()&lt;/code&gt;. No containers, no emulator:&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;Run_WithBatchOfThreeEvents_CallsProcessorThreeTimes&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;processor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Substitute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ISensorProcessor&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;function&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;SensorReadingFunction&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;SensorReadingFunction&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="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;EventData&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nf"&gt;CreateEventData&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;SensorReading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"device-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;22.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;60.0&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="nf"&gt;CreateEventData&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;SensorReading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"device-02"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;25.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;55.0&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="nf"&gt;CreateEventData&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;SensorReading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"device-03"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;18.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;72.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&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;processor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Received&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="nf"&gt;ProcessAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Arg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SensorReading&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;EventData&lt;/span&gt; &lt;span class="nf"&gt;CreateEventData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SensorReading&lt;/span&gt; &lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SerializeToUtf8Bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This catches deserialization bugs and verifies the batch loop without starting anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Full trigger integration test
&lt;/h3&gt;

&lt;p&gt;Unit tests verify the dispatch and deserialization logic in isolation. The full pipeline test goes further: a real message flows from Event Hubs through the function and into Cosmos DB.&lt;/p&gt;

&lt;p&gt;The fixture starts three containers in parallel (Azurite for the Functions runtime, the Event Hubs emulator, and the Cosmos DB emulator), then launches &lt;code&gt;func start&lt;/code&gt; as a child process wired to all three. The full source is in &lt;a href="https://github.com/martinoehlert/azure-functions-samples/blob/main/EventHubDemo.Tests/SensorPipelineFixture.cs" rel="noopener noreferrer"&gt;SensorPipelineFixture.cs&lt;/a&gt;. The container declarations and process wiring both require non-obvious configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Container configuration:&lt;/strong&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;// Use latest: Core Tools 4.8 sends an API version that Azurite 3.28.0 (the Testcontainers default) rejects.&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;AzuriteContainer&lt;/span&gt; &lt;span class="n"&gt;_azurite&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;AzuriteBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mcr.microsoft.com/azure-storage/azurite:latest"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// WithPortBinding pins the host port so localhost:8081 resolves from the func child process.&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;CosmosDbContainer&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;new&lt;/span&gt; &lt;span class="nf"&gt;CosmosDbBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithPortBinding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8081&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;8081&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithWaitStrategy&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="nf"&gt;ForUnixContainer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UntilMessageIsLogged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Gateway=OK, Explorer=OK"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;EventHubsContainer&lt;/span&gt; &lt;span class="n"&gt;_eventHubs&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;SensorPipelineFixture&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_eventHubs&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;EventHubsBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithAcceptLicenseAgreement&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="nf"&gt;WithConfigurationBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EventHubsServiceConfiguration&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sensor-readings"&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="nf"&gt;WithWaitStrategy&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="nf"&gt;ForUnixContainer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UntilMessageIsLogged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Emulator Service is Successfully Up!"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Testcontainers pins &lt;code&gt;azurite:3.28.0&lt;/code&gt; as its default. Azure Functions Core Tools 4.8 sends API version &lt;code&gt;2024-08-04&lt;/code&gt;; Azurite 3.28.0 rejects that version with a 400. Pinning to &lt;code&gt;latest&lt;/code&gt; resolves it.&lt;/p&gt;

&lt;p&gt;Both the Event Hubs emulator and the Cosmos &lt;code&gt;vnext-preview&lt;/code&gt; image are distroless: no shell, no &lt;code&gt;/bin/sh&lt;/code&gt;. The default Testcontainers port-check wait strategy execs &lt;code&gt;/bin/sh&lt;/code&gt; inside the container to verify the port is listening. On a distroless image, that exec fails and the strategy hangs indefinitely. &lt;code&gt;UntilMessageIsLogged()&lt;/code&gt; watches the container's stdout stream directly, bypassing the shell dependency.&lt;/p&gt;

&lt;p&gt;The Cosmos emulator returns its own internal address in the account metadata it sends back to clients. The test-process &lt;code&gt;CosmosClient&lt;/code&gt; receives &lt;code&gt;localhost:8081&lt;/code&gt; as the endpoint and follows it there. &lt;code&gt;WithPortBinding(8081, 8081)&lt;/code&gt; ensures that host port is pinned, so the &lt;code&gt;func&lt;/code&gt; child process (which constructs its own &lt;code&gt;CosmosClient&lt;/code&gt;) lands on the same address.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;WithResourceMapping&lt;/code&gt; mounts a JSON configuration file into the Event Hubs emulator container, but it doesn't set the &lt;code&gt;ServiceConfiguration&lt;/code&gt; property the builder reads at &lt;code&gt;Build()&lt;/code&gt; time. The build throws at runtime. &lt;code&gt;WithConfigurationBuilder&lt;/code&gt; uses the fluent API to set &lt;code&gt;ServiceConfiguration&lt;/code&gt; directly, and the configuration is validated at build time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Process wiring:&lt;/strong&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;cosmosPort&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="nf"&gt;GetMappedPublicPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8081&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;cosmosKey&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="nf"&gt;GetConnectionString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;';'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;First&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"AccountKey="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"AccountKey="&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="n"&gt;CosmosClient&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;CosmosClient&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="nf"&gt;GetConnectionString&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;CosmosClientOptions&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ConnectionMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ConnectionMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Gateway&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;HttpClientFactory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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="nf"&gt;CosmosEmulatorHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cosmosPort&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;SerializerOptions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;CosmosSerializationOptions&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;PropertyNamingPolicy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CosmosPropertyNamingPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CamelCase&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;startInfo&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="s"&gt;"CosmosDbConnection"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="s"&gt;$"AccountEndpoint=http://localhost:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cosmosPort&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/;AccountKey=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cosmosKey&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;The &lt;code&gt;func&lt;/code&gt; child process constructs its own &lt;code&gt;CosmosClient&lt;/code&gt; from the &lt;code&gt;CosmosDbConnection&lt;/code&gt; environment variable; it can't share the test process's &lt;code&gt;HttpClient&lt;/code&gt; handler across the process boundary. Passing &lt;code&gt;AccountEndpoint=http://localhost:{port}/&lt;/code&gt; with an explicitly extracted key gives the child process a direct HTTP connection to the emulator without needing the handler.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CosmosEmulatorHandler&lt;/code&gt; is an &lt;code&gt;HttpMessageHandler&lt;/code&gt; that rewrites outgoing requests from the emulator's self-reported internal hostname to &lt;code&gt;localhost:{cosmosPort}&lt;/code&gt;. Without it, the SDK follows the internal address the emulator returns in its account metadata and misses the container.&lt;/p&gt;

&lt;p&gt;The full fixture also implements &lt;code&gt;WaitForFunctionsHostAsync&lt;/code&gt; (polls &lt;code&gt;localhost:7071/admin/host/status&lt;/code&gt; until the host responds) and &lt;code&gt;DisposeAsync&lt;/code&gt; (kills the process tree and disposes all three containers). Both are in the &lt;a href="https://github.com/martinoehlert/azure-functions-samples/blob/main/EventHubDemo.Tests/SensorPipelineFixture.cs" rel="noopener noreferrer"&gt;full source&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The test publishes a batch and polls Cosmos DB until the document appears:&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;Collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SensorPipelineFixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SensorPipelineTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SensorPipelineFixture&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;PublishedEvent_WithValidReading_AppearsInCosmosDb&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;reading&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;SensorReading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;DeviceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$"device-&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="n"&gt;N&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;Temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;23.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Humidity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;58.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Timestamp&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="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;await&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProducerClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBatchAsync&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="nf"&gt;TryAdd&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;EventData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SerializeToUtf8Bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reading&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;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProducerClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;container&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CosmosClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetContainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SensorData"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"readings"&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;deadline&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="nf"&gt;AddSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="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;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;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetItemQueryIterator&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
                &lt;span class="s"&gt;$"SELECT * FROM c WHERE c.deviceId = '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeviceId&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;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clear&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;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasMoreResults&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;AddRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadNextAsync&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;results&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;&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="k"&gt;break&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="m"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Single&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="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;23.4&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;results&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;temperature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fixture takes 60–90 seconds to start. Run it separately from unit tests in CI using xUnit's &lt;code&gt;[Collection]&lt;/code&gt; trait or a test filter.&lt;/p&gt;

&lt;p&gt;Add the packages:&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;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"Testcontainers.EventHubs"&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;"Testcontainers.CosmosDb"&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;"Testcontainers.Azurite"&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;"Azure.Messaging.EventHubs"&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;"Newtonsoft.Json"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cosmos SDK v3 requires Newtonsoft.Json at runtime via an internal dependency. Omitting it produces a &lt;code&gt;FileNotFoundException&lt;/code&gt; at startup with no message connecting it to Cosmos.&lt;/p&gt;




&lt;h2&gt;
  
  
  What can't be emulated locally
&lt;/h2&gt;

&lt;p&gt;Azurite and &lt;code&gt;func start&lt;/code&gt; cover wiring and trigger dispatch. Some behaviors only emerge in Azure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold starts.&lt;/strong&gt; Local tests keep the host warm throughout the run. Consumption plan cold starts in Azure hit 500ms-2s for .NET depending on deployment size. If your SLA depends on p99 latency, that gap only shows in production traffic — local tests give you no signal on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Managed identity credential resolution.&lt;/strong&gt; &lt;code&gt;DefaultAzureCredential&lt;/code&gt; falls through a chain of credential sources. Locally it uses developer machine credentials or environment variables. In Azure it uses the Managed Identity endpoint. A misconfigured client ID or missing role assignment won't surface until the function runs with a real identity attached. The local credential chain doesn't exercise the same code path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scale-out behavior.&lt;/strong&gt; &lt;code&gt;func start&lt;/code&gt; runs one worker. Azure scales to N workers based on trigger backlog. Race conditions, partition contention, and shared-state bugs appear only under concurrent load across multiple instances. No local setup replicates this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KEDA-based scaling decisions.&lt;/strong&gt; Event Hub and Service Bus triggers scale based on message lag, but the scaling decisions come from the infrastructure, not the worker process. There's no local equivalent for how Azure routes partitions across workers as instances scale up.&lt;/p&gt;

&lt;p&gt;The useful takeaway: unit tests and integration tests give fast, reliable feedback on logic and wiring. They don't give confidence about latency under cold conditions, behavior at scale, or cloud-managed auth. Build those signals from production observability (Application Insights, structured logs, alert rules), not from test infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Patterns that cause pain
&lt;/h2&gt;

&lt;p&gt;A few mistakes appear repeatedly in Azure Functions test suites.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asserting on log messages.&lt;/strong&gt; &lt;code&gt;Substitute.For&amp;lt;ILogger&amp;lt;T&amp;gt;&amp;gt;()&lt;/code&gt; lets you verify that specific log calls were made. Don't. Log messages are implementation details: they change wording, get split into multiple calls, or get removed during refactoring. When they do, your test breaks without any behavior change. Use &lt;code&gt;NullLogger&amp;lt;T&amp;gt;.Instance&lt;/code&gt; for services and only substitute loggers when logging output is the actual behavior under test (which is almost never).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reaching into the runtime from unit tests.&lt;/strong&gt; &lt;code&gt;[HttpTrigger]&lt;/code&gt;, &lt;code&gt;[FromBody]&lt;/code&gt;, and &lt;code&gt;[QueueTrigger]&lt;/code&gt; are metadata for the runtime to read. They don't execute during a direct method call. Trying to test that binding attributes are present, or that the runtime would route correctly, puts you in the business of testing the Functions SDK rather than your code. The routing table lives in the host config; your job is to test what happens once the host calls your method.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Using constructors for container lifecycle.&lt;/strong&gt; xUnit creates test class instances before running tests, but &lt;code&gt;StartAsync()&lt;/code&gt; is async. Initializing a &lt;code&gt;AzuriteContainer&lt;/code&gt; in a constructor blocks the thread and causes tests to hang silently. Always use &lt;code&gt;IAsyncLifetime&lt;/code&gt;: &lt;code&gt;InitializeAsync&lt;/code&gt; for startup, &lt;code&gt;DisposeAsync&lt;/code&gt; for teardown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing the service layer twice.&lt;/strong&gt; Once you have thorough &lt;code&gt;OrderServiceTests&lt;/code&gt;, the function-level tests (&lt;code&gt;OrderFunctionTests&lt;/code&gt;) should only cover the HTTP response mapping: does a successful result return 201, does a failure return 400. Repeating the validation and business logic assertions at the function level creates duplicate coverage that breaks together whenever the service contract changes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Choosing your testing strategy
&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%2Fu2d2hp69aadyyzdcj8au.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%2Fu2d2hp69aadyyzdcj8au.png" alt="Azure Functions Testing Pyramid" width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Infrastructure needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Service logic&lt;/td&gt;
&lt;td&gt;Unit test&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Function routing&lt;/td&gt;
&lt;td&gt;Unit test&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DI wiring + middleware&lt;/td&gt;
&lt;td&gt;HostBuilder trick&lt;/td&gt;
&lt;td&gt;None (gRPC stripped)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data layer round-trips&lt;/td&gt;
&lt;td&gt;Testcontainers (SQL/Postgres/Azurite)&lt;/td&gt;
&lt;td&gt;Docker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger dispatch&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;func start&lt;/code&gt; + Azurite&lt;/td&gt;
&lt;td&gt;Core Tools + Docker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full pipeline&lt;/td&gt;
&lt;td&gt;Testcontainers Docker image&lt;/td&gt;
&lt;td&gt;Docker&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Start from the top and stop as soon as the tests cover the risk you're managing. For most business logic, unit tests against the service layer are enough. The function class tests add a few minutes of coverage for the HTTP response shapes. Integration and E2E tests are worth the infrastructure cost only when you need to verify wiring, real database behavior, or trigger dispatch.&lt;/p&gt;




&lt;p&gt;Do you unit test your function class directly, or do you treat the service layer as the boundary and skip function-level tests entirely?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Azure Functions for .NET Developers: Series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: &lt;a href="https://dev.to/martin_oehlert/why-azure-functions-serverless-for-net-developers-707"&gt;Why Azure Functions? Serverless for .NET Developers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 2: &lt;a href="https://dev.to/martin_oehlert/your-first-azure-function-http-triggers-step-by-step-ib8"&gt;Your First Azure Function: HTTP Triggers Step-by-Step&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 3: &lt;a href="https://dev.to/martin_oehlert/beyond-http-timer-queue-and-blob-triggers-5aj5"&gt;Beyond HTTP: Timer, Queue, and Blob Triggers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 4: &lt;a href="https://dev.to/martin_oehlert/local-development-setup-tools-debugging-and-hot-reload-2925"&gt;Local Development Setup: Tools, Debugging, and Hot Reload&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 5: &lt;a href="https://dev.to/martin_oehlert/understanding-the-isolated-worker-model-5gd4"&gt;Understanding the Isolated Worker Model&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 6: &lt;a href="https://dev.to/martin_oehlert/configuration-done-right-settings-secrets-and-key-vault-3n7h"&gt;Configuration Done Right: Settings, Secrets, and Key Vault&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 7: Testing Azure Functions: Unit, Integration, and Local (this article)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Part 8: &lt;a href="https://dev.to/martin_oehlert/deploying-to-azure-cicd-with-github-actions-141m"&gt;Deploying to Azure: CI/CD with GitHub Actions&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 9: &lt;a href="https://dev.to/martin_oehlert/azure-functions-observability-from-blind-spots-to-production-clarity-24j4"&gt;Azure Functions Observability: From Blind Spots to Production Clarity&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Bonus: &lt;a href="https://dev.to/martin_oehlert/production-realities-when-azure-functions-stops-being-serverless-p2g"&gt;Production Realities: When Serverless Stops Being Serverless&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

</description>
      <category>azure</category>
      <category>azurefunctions</category>
      <category>serverless</category>
      <category>dotnet</category>
    </item>
  </channel>
</rss>
