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 BackgroundService 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 Program.cs change instead of a rewrite, and tests stop needing the Functions host.
The pattern shows up most clearly in a working sample. The MigrationDemo folder from Part 5 ships one workload (Settlement.Core) and three hosts (Functions, App Service, Container App) that all call into it. Everything below is grounded in that code.
The trigger as a thin controller
The Function method does three things and nothing else:
- Receive the trigger payload. For queue, event hub, and Cosmos triggers the binding deserializes it for you. HTTP triggers do their own
ReadFromJsonAsync. - Call an injected service with the deserialized command and a
CancellationToken. - Return or forward the result.
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.
Before: the trigger doing too much
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 Run method:
public sealed class SettlementFunction(
QueueClient deadLetterQueue,
ILogger<SettlementFunction> logger)
{
[Function(nameof(SettlementFunction))]
public async Task Run(
[QueueTrigger("settlement-batches", Connection = "AzureWebJobsStorage")] string body,
CancellationToken cancellationToken)
{
var batch = JsonSerializer.Deserialize<SettlementBatch>(body)
?? throw new InvalidOperationException("Malformed batch.");
var delayMs = int.Parse(Environment.GetEnvironmentVariable("PER_PAYMENT_DELAY_MS") ?? "50");
var failureRate = double.Parse(Environment.GetEnvironmentVariable("FAILURE_RATE") ?? "0.02");
var settled = 0;
var failed = 0;
foreach (var payment in batch.Payments)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(delayMs, cancellationToken);
var hash = (uint)payment.PaymentId.GetHashCode(StringComparison.Ordinal);
var accepted = ((hash & 0xFFFF) / 65536.0) >= failureRate;
if (accepted)
{
settled++;
}
else
{
failed++;
logger.LogWarning("Rejected {PaymentId}", payment.PaymentId);
await deadLetterQueue.SendMessageAsync(
JsonSerializer.Serialize(new { payment.PaymentId, reason = "GATEWAY_DECLINED" }),
cancellationToken: cancellationToken);
}
}
logger.LogInformation(
"Batch {BatchId}: settled={Settled}, failed={Failed}",
batch.BatchId, settled, failed);
}
}
Three problems are not visible in the file, but they are visible the first time something goes wrong:
- The settlement loop only runs inside the Functions worker. A Consumption-plan batch that runs past the 10-minute default timeout gets killed, and the same message comes back from scratch.
- Changing the failure-rate threshold means redeploying the Function. Nothing in the loop is testable without spinning up the host.
- 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.
After: the contract in code
Push the loop into IPaymentSettler and the trigger collapses to its three jobs. From Settlement.FunctionApp/SettlementFunction.cs:
public sealed class SettlementFunction(
IPaymentSettler settler,
ILogger<SettlementFunction> logger)
{
[Function(nameof(SettlementFunction))]
public async Task Run(
[QueueTrigger("settlement-batches", Connection = "AzureWebJobsStorage")] SettlementBatch batch,
CancellationToken cancellationToken)
{
logger.LogInformation(
"Function host received batch {BatchId} ({Count} payments)",
batch.BatchId, batch.Payments.Count);
var result = await settler.SettleAsync(batch, progress: null, cancellationToken);
logger.LogInformation(
"Function host completed batch {BatchId}: settled={Settled}, failed={Failed}",
batch.BatchId, result.Settled, result.Failed);
}
}
Five statements. The QueueTrigger binding deserializes the message into a SettlementBatch POCO before Run is called; Microsoft Learn confirms this in the queue trigger usage notes. The body of the workload sits behind IPaymentSettler.SettleAsync(SettlementBatch, IProgress<SettlementProgress>?, CancellationToken). The CancellationToken propagates through.
What does not belong, with evidence from the sample
Each rule below would show up as code in SettlementFunction.cs if it had been violated. None of them is.
-
Business rules. No validation, no per-payment branching, no calculation in
Run. The accept/reject branch and the rejection log live inPaymentSettler.SettleAsync. -
Direct Azure SDK calls.
SettlementFunction.cshas nousing Azure.Storage.*and never constructs aQueueClientorBlobClient. The trigger reaches the downstream payments network throughIPaymentSettler, which depends onISettlementGateway, which speaks the domain. -
FunctionContextleakage. NeitherRunnorIPaymentSettlerdeclaresFunctionContext. That type ships inMicrosoft.Azure.Functions.Worker; if a service took it as a parameter, that service would not compile in the App Service or Container App project. -
Environment-variable reads. No
Environment.GetEnvironmentVariableanywhere inSettlement.Core. Configuration isIOptions<SettlementOptions>andIOptions<PaymentSettlerOptions>, bound identically across all three hosts. -
Sink-specific logging.
SettlementFunctiontakesILogger<SettlementFunction>. NoTelemetryClient, noFunctionContext.GetLogger.
Why thinness matters when the host changes
Three things in the after-snippet are bound to Microsoft.Azure.Functions.Worker: the [Function] attribute, the [QueueTrigger] attribute, and the dispatch contract that says the worker calls Run. When the host changes, those three things get rewritten. Whatever sits below the settler.SettleAsync(...) call site survives.
The MigrationDemo sample makes that claim measurable. The same IPaymentSettler.SettleAsync call appears in three host projects:
-
Settlement.FunctionApp/SettlementFunction.cs: queue trigger, five statements. -
Settlement.AppService/Services/SettlementWorker.cs: aBackgroundServicepolling the same queue, with anIProgress<SettlementProgress>reporter feeding a/statusendpoint. -
Settlement.ContainerApp/SettlementWorker.cs: the same polling loop with no HTTP host.
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.
Abstracting Azure SDK dependencies
The first temptation when "separating logic" is to wrap every SDK type. IBlobStore around BlobClient. IQueueClientWrapper around QueueClient. IServiceBusSenderWrapper around ServiceBusSender. 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.
MigrationDemo cuts in a different place. It abstracts the workload shape, not the transport:
public interface ISettlementGateway
{
Task<SettlementResponse> SubmitAsync(
Payment payment,
CancellationToken cancellationToken);
}
public interface IPaymentSettler
{
Task<SettlementProgress> SettleAsync(
SettlementBatch batch,
IProgress<SettlementProgress>? progress,
CancellationToken cancellationToken);
}
Both interfaces speak the domain. Payment and SettlementBatch are records in Settlement.Core/Models/. The downstream payments network is an ISettlementGateway; the demo wires FakeSettlementGateway (deterministic, fast, no network), and production swaps in a typed HttpClient against the real provider. The workload entrypoint is IPaymentSettler. One method each. No BlobClient, no QueueClient, no ServiceBusSender anywhere in the file.
Infrastructure clients live above Settlement.Core
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 QueueServiceClient via Microsoft.Extensions.Azure. From Settlement.AppService/Program.cs:
builder.Services.AddAzureClients(clientBuilder =>
{
if (!string.IsNullOrWhiteSpace(queueConnection))
{
clientBuilder.AddQueueServiceClient(queueConnection);
}
else if (!string.IsNullOrWhiteSpace(queueServiceUri))
{
clientBuilder.AddQueueServiceClient(new Uri(queueServiceUri));
clientBuilder.UseCredential(new DefaultAzureCredential());
}
else
{
throw new InvalidOperationException(
"Queue:ConnectionString or Queue:ServiceUri must be configured.");
}
});
Two branches, one shared registration. The ConnectionString branch is for local development against Azurite; the ServiceUri branch uses DefaultAzureCredential so the production host authenticates with managed identity. The choice is config-driven, so the composition root does not change between dev and prod.
QueueServiceClient is the per-account singleton. The per-queue QueueClient is derived from it:
builder.Services.AddSingleton(sp =>
{
var service = sp.GetRequiredService<QueueServiceClient>();
var name = sp.GetRequiredService<IOptions<QueueOptions>>().Value.QueueName;
return service.GetQueueClient(name);
});
SettlementWorker keeps its existing QueueClient constructor parameter; only the wiring above changed. The Container App host has the same block, with one config object difference.
The Functions host does not call AddAzureClients
Settlement.FunctionApp/Program.cs skips this whole section. It does not need to. The [QueueTrigger("settlement-batches", Connection = "AzureWebJobsStorage")] attribute reads the storage connection from the host's AzureWebJobsStorage setting and manages the client itself. To run the Function host on managed identity instead of a connection string, set AzureWebJobsStorage__queueServiceUri on the app's configuration and grant the function app's identity the Storage Queue Data Contributor role on the storage account. Microsoft Learn documents the suffix pattern in identity-based connections for Functions. That is a hosting change, not a code change.
Keep the asymmetry in mind when reading the three Program.cs files side by side. The App Service and Container App hosts manage the queue client themselves because their BackgroundService is the activation surface. The Function host hands that job to the binding.
Building portable service classes
A service class is portable when three rules hold. Each one rules out a specific failure I have watched bite during migration.
Rule 1: zero references to Microsoft.Azure.Functions.* in the project file
The portability claim has to survive grep. In MigrationDemo:
$ 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)
Settlement.Core.csproj declares three direct references, all Microsoft.Extensions.*: DI abstractions, logging abstractions, options. If anything in that list grows a transitive Microsoft.Azure.Functions.* 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.
Rule 2: configuration via IOptions<T> with validated binding
PaymentSettler reads its only knob through PaymentSettlerOptions:
public sealed class PaymentSettlerOptions
{
public const string SectionName = "PaymentSettler";
[Range(1, 100_000)]
public int ProgressReportInterval { get; init; } = 1;
}
Every host binds the same options the same way:
builder.Services
.AddOptions<PaymentSettlerOptions>()
.Bind(builder.Configuration.GetSection(PaymentSettlerOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
What changes between hosts is the source of the value, not the shape:
- Functions:
PaymentSettler__ProgressReportIntervalas an app setting, orValues:PaymentSettler:ProgressReportIntervalinlocal.settings.json. The double underscore is the cross-platform environment-variable hierarchy delimiter that maps to:in the configuration tree. - App Service: nested JSON in
appsettings.Development.json({ "PaymentSettler": { "ProgressReportInterval": 50 } }) or the same env-var shape in the portal. - Container App: nested JSON plus the option to inject
PaymentSettler__ProgressReportIntervalat runtime, often via a Container Apps secret.
IConfiguration collapses all three sources into one binding call. PaymentSettler only ever sees IOptions<PaymentSettlerOptions>. 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.
Rule 3: logging via ILogger<T>
PaymentSettler takes ILogger<PaymentSettler> in its primary constructor. Nothing else. No TelemetryClient, no FunctionContext.GetLogger, no Console.WriteLine. The sink (Application Insights, OpenTelemetry, console) is wired in each host's Program.cs; the workload never sees which one is registered.
The moment a service starts using TelemetryClient 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.
The BackgroundService lifetime trap
Settlement.Core/Services/ServiceCollectionExtensions.cs registers IPaymentSettler as a singleton:
public static IServiceCollection AddSettlementCore(this IServiceCollection services)
{
services.TryAddSingleton<IPaymentSettler, PaymentSettler>();
return services;
}
That works because PaymentSettler is stateless past its IOptions<> snapshot. The minute a real workload grows a scoped dependency (a DbContext, a tenant-bound HttpClient), the lifetime story diverges across hosts:
- The isolated Functions worker creates a scope per invocation. A scoped
DbContextgets a fresh instance per message. - ASP.NET creates a scope per HTTP request, but a
BackgroundServiceruns in the root scope. Injecting a scoped service into aBackgroundServiceconstructor throws on startup. Microsoft Learn flags the constraint in the hosted-services guidance. - The Container App host hits the same root-scope rule because its activation surface is also a
BackgroundService.
The fix is to inject IServiceScopeFactory into the BackgroundService and call CreateScope() 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.
Testing without the Functions runtime
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 IPaymentSettler and the test becomes plain xUnit construction against the service class.
PaymentSettler takes three constructor parameters, all in Microsoft.Extensions.*. The unit test resolves them by hand:
[Fact]
public async Task SettleAsync_with_all_accepting_gateway_reports_full_settlement()
{
var batch = new SettlementBatch(
BatchId: "test-1",
CutoffUtc: DateTimeOffset.UtcNow,
Payments: Enumerable.Range(0, 10)
.Select(i => new Payment($"p-{i}", 100m, "EUR"))
.ToList());
var settler = new PaymentSettler(
gateway: new AlwaysAcceptingGateway(),
options: Options.Create(new PaymentSettlerOptions { ProgressReportInterval = 1 }),
logger: NullLogger<PaymentSettler>.Instance);
var result = await settler.SettleAsync(batch, progress: null, CancellationToken.None);
Assert.Equal(10, result.Settled);
Assert.Equal(0, result.Failed);
}
private sealed class AlwaysAcceptingGateway : ISettlementGateway
{
public Task<SettlementResponse> SubmitAsync(Payment payment, CancellationToken ct) =>
Task.FromResult(new SettlementResponse(payment.PaymentId, Accepted: true, ReasonCode: null));
}
No TestHostBuilder, no local.settings.json, no func start. The test runs in milliseconds. The hand-rolled AlwaysAcceptingGateway is the lever that makes the assertion deterministic: the FakeSettlementGateway 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 Azure SDK unit-testing guide shows both options for Settlement.Core-style services: a hand-rolled subclass of the dependency (which is what AlwaysAcceptingGateway 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.
Integration tests at the same boundary
The integration suite swaps the fake for the real implementation (a typed HttpClient against the payments network) and runs the same SettleAsync call. The trigger is still not in the picture; dotnet test walks the workload directly. For the App Service and Container App variants, the queue surface drains through QueueClient against Azurite. 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.
The Functions variant has no first-party in-process test host for the isolated worker. The integration story there is "run func start once per build and stimulate the queue." Microsoft documents one in-process Functions test fixture, Microsoft.DurableTask.InProcessTestHost, in the Durable Functions unit-testing guide, 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.
The smoke test that earns its keep
One end-to-end test per build, run against func start, confirms the binding wires up. Its job is to catch "the queue name in host.json 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.
Before and after: a refactoring walkthrough
The trigger before-and-after is above. The two remaining pieces of the diff are the service IPaymentSettler 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.
PaymentSettler.SettleAsync is what IPaymentSettler resolves to. The full method body fits on one screen, in Settlement.Core/Services/PaymentSettler.cs:
public async Task<SettlementProgress> SettleAsync(
SettlementBatch batch,
IProgress<SettlementProgress>? progress,
CancellationToken cancellationToken)
{
var settled = 0;
var failed = 0;
var total = batch.Payments.Count;
var processed = 0;
var interval = _options.ProgressReportInterval;
logger.LogInformation(
"Settling batch {BatchId}: total={Total}, cutoff={CutoffUtc:O}",
batch.BatchId, total, batch.CutoffUtc);
foreach (var payment in batch.Payments)
{
cancellationToken.ThrowIfCancellationRequested();
var response = await gateway.SubmitAsync(payment, cancellationToken);
if (response.Accepted)
{
settled++;
}
else
{
failed++;
logger.LogWarning(
"Settlement rejected for {PaymentId}: {ReasonCode}",
payment.PaymentId, response.ReasonCode);
}
processed++;
if (processed % interval == 0 || processed == total)
{
progress?.Report(new SettlementProgress(settled, failed, total));
}
}
var final = new SettlementProgress(settled, failed, total);
logger.LogInformation(
"Batch {BatchId} settled: settled={Settled}, failed={Failed}",
batch.BatchId, final.Settled, final.Failed);
return final;
}
Constructor dependencies: ISettlementGateway, IOptions<PaymentSettlerOptions>, ILogger<PaymentSettler>. No Microsoft.Azure.Functions.*. No Azure.Storage.*. The host's identity (queue trigger vs BackgroundService vs anything else) is below the abstraction.
The App Service host is one of the hosts below it. Settlement.AppService/Services/SettlementWorker.cs extends BackgroundService, drains the same queue the Function reads, and dispatches each message through ProcessAsync:
private async Task ProcessAsync(QueueMessage message, CancellationToken cancellationToken)
{
SettlementBatch? batch;
try
{
batch = JsonSerializer.Deserialize<SettlementBatch>(message.Body.ToString());
}
catch (JsonException ex)
{
logger.LogError(ex, "Discarding malformed message {MessageId}", message.MessageId);
await queueClient.DeleteMessageAsync(message.MessageId, message.PopReceipt, cancellationToken);
return;
}
if (batch is null)
{
await queueClient.DeleteMessageAsync(message.MessageId, message.PopReceipt, cancellationToken);
return;
}
var progress = new Progress<SettlementProgress>(p => status.Update(batch.BatchId, p));
try
{
var result = await settler.SettleAsync(batch, progress, cancellationToken);
status.Complete(batch.BatchId, result);
await queueClient.DeleteMessageAsync(message.MessageId, message.PopReceipt, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
logger.LogError(
ex,
"Settlement of batch {BatchId} failed; message will reappear after visibility timeout",
batch.BatchId);
}
}
The dispatch line that matters is await settler.SettleAsync(batch, progress, cancellationToken);. Same IPaymentSettler, same three-argument signature, same cancellation propagation as the Function. The App Service variant passes a real IProgress<SettlementProgress> (the worker keeps a live /status endpoint backed by SettlementWorkerStatus); the Function and Container App variants pass null. That is the entire workload diff between the three hosts.
What does differ between hosts is composition root and activation surface. The Function App's Program.cs is shorter because the binding owns the queue client; the App Service and Container App hosts register QueueServiceClient via AddAzureClients and derive QueueClient from it. The activation surface is [QueueTrigger] for the Function, BackgroundService.ExecuteAsync for the other two. Everything below those lines, AddSettlementCore(), the gateway registration, the options binding, the logger pipeline, is shared verbatim. The host moves; the workload does not.
Series wrap-up: where to go from here
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.
Running Azure Functions in Docker and Docker Pitfalls I Hit packaged the Function App into a container so the runtime moved with the code. Scaling Azure Functions made the cost ceilings visible by walking the Consumption, Premium, and Dedicated plans against real workloads. Structuring Complex Function Apps reorganised the project structure so refactoring stayed tractable as the function count grew. When Azure Functions Fight Back 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 IPaymentSettler, the diff between Function App, App Service, and Container App is a Program.cs change.
Three places to go from here:
-
Series 3 (forthcoming) integrates .NET Aspire into the Functions workflow: AppHost orchestration, Service Bus, Storage, and Redis as Aspire resources, and
azddeployment to Container Apps. The decoupledSettlement.Corelibrary carries straight across; only the composition root learns about Aspire. -
The
MigrationDemosample 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. - Microsoft Learn migration guidance covers the surface this article does not: moving from in-process to isolated worker (still relevant as the decoupling that makes any further migration tractable) and the broader App Service migration overview when the destination is not Functions at all.
The decoupling work earns its keep the first time a queue handler runs past 10 minutes and the answer is "register a BackgroundService against the same queue" instead of "rewrite the workload".
Closing question
Which Azure SDK type was hardest for you to push behind an interface: BlobClient, ServiceBusSender, or something else? Reply with the type name.
Azure Functions Beyond the Basics
Continues from Azure Functions for .NET Developers (Parts 1-9)
- Part 1: Running Azure Functions in Docker: Why and How
- Part 2: Docker Pitfalls I Hit (And How to Avoid Them)
- Part 3: Scaling Azure Functions: Consumption vs Premium vs Dedicated
- Part 4: Structuring Complex Function Apps: Project Organization
- Part 5: When Azure Functions Fight Back: Signs You've Outgrown Them
- Part 6: Preparing for Migration: Decoupling Your Function Logic (this article)
Top comments (0)