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 gets killed, and the same message comes back from scratch.
After: the contract in code
Push the loop into IPaymentSettler and the trigger collapses to its three jobs. From . 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 inRun. The accept/reject branch and the rejection log live in . FromSettlement.AppService/Program.cs:
CODEbuilder.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
ConnectionStringbranch is for local development against Azurite; theServiceUribranch uses . That is a hosting change, not a code change.
Keep the asymmetry in mind when reading the three
Program.csfiles side by side. The App Service and Container App hosts manage the queue client themselves because theirBackgroundServiceis 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 toMicrosoft.Azure.Functions.*in the project file
The portability claim has to survive
grep. InMigrationDemo:
CODE$ 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.csprojdeclares three direct references, allMicrosoft.Extensions.*: DI abstractions, logging abstractions, options. If anything in that list grows a transitiveMicrosoft.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 viaIOptions<T>with validated binding
PaymentSettlerreads its only knob throughPaymentSettlerOptions:
CODEpublic 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:
CODEbuilder.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 . - The Container App host hits the same root-scope rule because its activation surface is also a
BackgroundService.
The fix is to inject
IServiceScopeFactoryinto theBackgroundServiceand callCreateScope()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
IPaymentSettlerand the test becomes plain xUnit construction against the service class.
PaymentSettlertakes three constructor parameters, all inMicrosoft.Extensions.*. The unit test resolves them by hand:
CODE[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, nolocal.settings.json, nofunc start. The test runs in milliseconds. The hand-rolledAlwaysAcceptingGatewayis the lever that makes the assertion deterministic: theFakeSettlementGatewayshipped 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 . 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 startonce per build and stimulate the queue." Microsoft documents one in-process Functions test fixture,Microsoft.DurableTask.InProcessTestHost, in the and made the cost ceilings visible by walking the Consumption, Premium, and Dedicated plans against real workloads. 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 behindIPaymentSettler, the diff between Function App, App Service, and Container App is aProgram.cschange.
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, andazddeployment to Container Apps. The decoupledSettlement.Corelibrary carries straight across; only the composition root learns about Aspire.
The (still relevant as the decoupling that makes any further migration tractable) and the broader (Parts 1-9)
- Part 1:
- Part 3:
- Part 5: When Azure Functions Fight Back: Signs You've Outgrown Them
- Part 6: Preparing for Migration: Decoupling Your Function Logic (this article)
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.- Part 1:
- Functions:
SOCIAL SHARE CARD GENERATOR