Trust is earned, not given

A different perspective

2025-07-15 · Projects

Azure Functions on .NET 10: Why the isolated worker model is now the only story

If you last touched Azure Functions when C# functions were a class library loaded into the Functions host process, .NET 10's message is stark: that model β€” in-process β€” is capped at .NET 8 forever. From .NET 9 onward, and for .NET 10, C# Functions run in the isolated worker model: your code is a separate process the host launches and speaks to. This article maps the two models, shows what changes in code you actually write, and explains why Microsoft's break was the right call.

The two models in one table

In-process (legacy)Isolated worker
RuntimeYour classes load into the Functions hostSeparate process; host spawns your dotnet.exe and proxies calls
.NET versions.NET Framework 4.8, .NET 6/8 only β€” hard cap.NET Framework 4.8, .NET 8/9/10 β€” anything current
Binding contractAttributes + framework types (HttpRequest, IActionResult)Attributes + worker types (HttpRequestData, HttpResponseData) or typed over HTTP
Dependency injectionOptional (WebJobs builder), easy to skipDefault β€” DI is the wiring model, not an opt-in
App settingsConfigurationManager plus options patterns Standard Microsoft.Extensions.Configuration
Startup costFast (same process)Slightly slower (process hop), improved by worker reuse
Couple of quirksHost's dependencies are yours; version collisions are the classic outageYou own every dependency; no host collisions possible

Why isolated had to win

The in-process design looked efficient β€” one process, no serialization β€” but it chained Functions to the host's runtime. Every ASP.NET Core or WebJobs version the host shipped was a version your app had to tolerate; AssemblyLoading conflicts were a genre of support ticket. Meanwhile .NET itself moved to a cadence in which each major version is a new runtime. A host that must load your code can only support runtimes it was built against β€” hence the .NET 8 ceiling. The isolated worker inverts the contract: the host stops loading your code and starts orchestrating a process that speaks a small gRPC-ish protocol. The host no longer cares which runtime the worker runs β€” which is why .NET 10 support arrived as "run your worker on .NET 10" rather than a host release.

What the code looks like now

dotnet new func --name McpFunction          # isolated template on .NET 10
# Program.cs - the entire model in 20 lines
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults()     // worker middleware pipeline
    .ConfigureServices(services =>
    {
        // Standard generic-host DI - the same code as any worker service.
        services.AddApplicationInsightsTelemetryWorkerService();
        services.ConfigureFunctionsApplicationInsights();
        services.AddHttpClient<IGenericApiClient, GenericApiClient>();
        services.AddSingleton<IKeyVaultCredentialProvider, KeyVaultCredentialProvider>();
    })
    .Build();

host.Run();

// A function: [Function] names it; the trigger attribute binds. Typed input,
// HttpResponseData out - no IActionResult, no HttpContext, no WebJobs types.
public class StatusFunctions(IGenericApiClient api)
{
    [Function("GetStatus")]
    public async Task<HttpResponseData> GetStatus(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "status/{id}")]
        HttpRequestData req, string id)
    {
        var status = await api.GetStatusAsync(id);
        var resp = req.CreateResponse(System.Net.HttpStatusCode.OK);
        await resp.WriteAsJsonAsync(status);
        return resp;
    }
}

The differences that actually bite

Typed bindings and the modern conveniences

.NET 8+ isolated model grew the features that make the migration feel like an upgrade: typed HTTP results (return Results.Ok(dto); from functions β€” no HttpResponseData plumbing), input bindings as parameters (queue/blob/table payloads as POCOs), and first-class support for the extension ecosystem β€” our MCP extension (Microsoft.Azure.Functions.Worker.Extensions.Mcp), for one, is isolated-only, as are several newer extensions. The extension gap closed in the worker model's favor.

// .NET 10 isolated: typed request AND response, POCO binding, no plumbing
public record IssueReport(string Id, string Severity);

[Function("IngestReport")]
public async Task<Results<Ok<string>, BadRequest<string>>> Ingest(
    [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req,
    [QueueOutput("reports", Connection = "MapleCartStorage")] out IssueReport? queued)
{
    var report = await req.ReadFromJsonAsync<IssueReport>();
    if (report is null) { queued = null; return "bad payload"; }   // BadRequest
    queued = report;                                               // Ok
    return "queued";
}

// And the same POCO binds as INPUT on a queue trigger - symmetric, typed:
[Function("ProcessReport")]
public async Task Process(
    [QueueTrigger("reports", Connection = "MapleCartStorage")] IssueReport report)
    => await _handler.HandleAsync(report);

Migrating an in-process app: the short version

  1. Switch the SDK: Microsoft.NET.Sdk.Functions β†’ Microsoft.Azure.Functions.Worker.Sdk, add Microsoft.Azure.Functions.Worker plus per-trigger extension packages.
  2. Add Program.cs with the generic host (DI, config, Insights) β€” the file above.
  3. Retype bindings: HttpRequest/IActionResult β†’ HttpRequestData/HttpResponseData or typed Results<,>; timer/queue/blob signatures change shape but not substance.
  4. Move any Startup : FunctionsStartup logic into ConfigureServices.
  5. Deploy with FUNCTIONS_WORKER_RUNTIME=dotnet-isolated set β€” the app setting that tells the host which worker to launch (and the one people forget, yielding a mystifying 500 on every function).

The result is a Functions app that is finally just a .NET worker service with triggers: standard DI, standard configuration, standard hosting, on whatever .NET you want β€” including .NET 10 β€” for as long as you want it. The in-process model bought milliseconds of startup by coupling you to a host; the isolated model sells those milliseconds back cheaply and hands you the whole runtime catalog. That trade was worth making, and 2025 is the year it became mandatory.