DEV Community

Cover image for Structuring Complex Function Apps: Project Organization
Martin Oehlert
Martin Oehlert

Posted on

Structuring Complex Function Apps: Project Organization

Your project is past 15 functions. The next one needs different host.json 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.

When one Function App is too many

Microsoft puts the soft cap at 100 event-based triggers per app. Past that, the scale controller silently stops looking: "When your app has more than 100 event-based triggers, scale decisions are made based on only the first 100 triggers that execute."

Long before you hit 100, four constraints start to bite:

  1. Scale. Every trigger in the app shares one scaling decision (with one Flex Consumption exception below).
  2. Deploy cadence. One PR redeploys every function in the project.
  3. Blast radius. Every function reads every connection string in app settings.
  4. host.json scope. One concurrency, timeout, and retry setting for the whole app.

The official guidance punts past that: "It's hard to say how many functions should be in a single app, which depends on your particular workload." That's the honest answer, but the four constraints above are what you're actually weighing when you get to "particular workload."

Monolith vs multiple Function Apps

Each of the four constraints has a doc-citable behaviour behind it.

Scale. On Consumption and Premium, the app is the scale unit: "all functions within a function app that share resources in an instance are scaled at the same time." One spike on a Service Bus trigger pulls every HTTP endpoint along for the ride, even if those endpoints are idle.

Flex Consumption is the exception. It scales per trigger group: 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.

The scale-out clock matters too: "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." 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.

Deploy cadence. "All functions in your local project are deployed together as a set of files." 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.

Blast radius. 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: "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." A single high-privilege connection string contaminates the whole app. The mitigation is a separate Function App with its own managed identity.

host.json scope. Settings in host.json apply to every function in the app within an instance. The worked example: "if you had a function app with two HTTP functions and maxConcurrentRequests set to 25, a request to either HTTP trigger would count towards the shared 25 concurrent requests." 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.

Cold start: how much does function count actually cost?

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.

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 func start 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:

Functions Median p10 p90 Δ vs 5-fn baseline
5 1528 ms 1481 ms 1673 ms +0 ms
15 1552 ms 1536 ms 1728 ms +24 ms
30 1548 ms 1542 ms 1561 ms +20 ms

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.

That changes the case for splitting. If you're splitting a 30-function app because of cold start, 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 host.json scope (one concurrency / timeout setting for the lot). Cold start is the argument that sounds intuitive and turns out not to land.

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.

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): ColdStartBenchmark/ in the companion repo.

Sharing code without copy-paste

Two Function Apps in one solution want the same Order record, the same OrderValidator, the same IOrderStore abstraction. Project reference is the default. You reach for an internal NuGet package only when project reference stops being enough.

<!-- OrderProcessor.Core/OrderProcessor.Core.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <RootNamespace>OrderProcessor.Core</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
    <PackageReference Include="Microsoft.Extensions.Options" />
  </ItemGroup>
</Project>
Enter fullscreen mode Exit fullscreen mode

Two things are missing on purpose:

  • No TargetFramework because the solution sets it centrally via Directory.Build.props. Whichever TFM the Function Apps use, this library matches. Microsoft's library guidance says: "DO start with including a net8.0 target or later for new libraries." If every consumer is .NET 10 isolated worker, target net10.0 and skip the netstandard2.0 ceremony.
  • No Microsoft.Azure.Functions.Worker.* packages. 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.

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

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 Functions/ folder in each app holds the trigger code. The shared library holds everything else.

<!-- OrderProcessor.Http/OrderProcessor.Http.csproj -->
<ItemGroup>
  <FrameworkReference Include="Microsoft.AspNetCore.App" />
  <PackageReference Include="Microsoft.Azure.Functions.Worker" />
  <PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
  <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
</ItemGroup>
<ItemGroup>
  <ProjectReference Include="..\OrderProcessor.Core\OrderProcessor.Core.csproj" />
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode

The HTTP app pulls in Http.AspNetCore. The Queue app pulls in Storage.Queues instead. Both reference Core for shared code.

The static-client exception

The "no statics in shared libraries" rule has a documented exception: connection-bearing clients. Microsoft's connection management guidance 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."

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

When project reference stops scaling

Project references work until you have more than one solution. The moment a second repo wants OrderProcessor.Core, 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. Azure Artifacts 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.

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.

Dependency injection with Keyed Services

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 IOrderStore. .NET 8 added Keyed Services so you don't have to invent a factory, a sentinel type, or a Func<string, IOrderStore> per backend.

namespace OrderProcessor.Core.Stores;

public interface IOrderStore
{
    Task<Order?> GetAsync(string orderId, CancellationToken cancellationToken);
    Task SaveAsync(Order order, CancellationToken cancellationToken);
}

public sealed class SqlOrderStore(ILogger<SqlOrderStore> logger) : IOrderStore
{
    // SQL implementation
}

public sealed class CosmosOrderStore(ILogger<CosmosOrderStore> logger) : IOrderStore
{
    // Cosmos implementation
}
Enter fullscreen mode Exit fullscreen mode

Both implementations register against the same interface, distinguished by a key:

public static class OrderStoreKeys
{
    public const string Sql = "sql";
    public const string Cosmos = "cosmos";
}

// OrderProcessor.Core/Services/ServiceCollectionExtensions.cs
public static IServiceCollection AddOrderServices(this IServiceCollection services)
{
    services.TryAddSingleton<OrderValidator>();

    services.TryAddKeyedSingleton<IOrderStore, SqlOrderStore>(OrderStoreKeys.Sql);
    services.TryAddKeyedSingleton<IOrderStore, CosmosOrderStore>(OrderStoreKeys.Cosmos);

    return services;
}
Enter fullscreen mode Exit fullscreen mode

Three details in the snippet are load-bearing:

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

Program.cs pulls it together:

// OrderProcessor.Http/Program.cs
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OrderProcessor.Core.Configuration;
using OrderProcessor.Core.Services;

var builder = FunctionsApplication.CreateBuilder(args);

builder.ConfigureFunctionsWebApplication();

builder.Services.Configure<OrderProcessingOptions>(
    builder.Configuration.GetSection("OrderProcessing"));

builder.Services.AddOrderServices();

builder.Build().Run();
Enter fullscreen mode Exit fullscreen mode

FunctionsApplication.CreateBuilder is the recommended builder for new isolated worker projects. It loads appsettings.json automatically, where new HostBuilder() does not. The Configure<OrderProcessingOptions> 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 IOptions<OrderProcessingOptions> and reads .Value. Wednesday's tip walks through IOptions<T> vs IOptionsSnapshot<T> for the cases where you need values to refresh at runtime.

Resolving the right key

The function class takes its IOrderStore directly on the primary constructor parameter:

// OrderProcessor.Http/Functions/CreateOrderFunction.cs
public sealed class CreateOrderFunction(
    ILogger<CreateOrderFunction> logger,
    OrderValidator validator,
    [FromKeyedServices(OrderStoreKeys.Sql)] IOrderStore primaryStore)
{
    [Function(nameof(CreateOrder))]
    public async Task<IActionResult> CreateOrder(
        [HttpTrigger(AuthorizationLevel.Function, "post", Route = "orders")] HttpRequest req,
        [FromBody] CreateOrderRequest request,
        CancellationToken cancellationToken)
    {
        var order = new Order(request.OrderId, request.CustomerId, request.Amount, OrderStatus.Pending);

        var validation = validator.Validate(order);
        if (!validation.IsValid)
        {
            return new BadRequestObjectResult(new { error = validation.Error });
        }

        await primaryStore.SaveAsync(order, cancellationToken);

        return new CreatedResult($"/api/orders/{order.OrderId}", order);
    }
}
Enter fullscreen mode Exit fullscreen mode

[FromKeyedServices] is AttributeUsage = AttributeTargets.Parameter, 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.

A second function in the same app can resolve a different key:

public sealed class GetOrderFunction(
    ILogger<GetOrderFunction> logger,
    [FromKeyedServices(OrderStoreKeys.Cosmos)] IOrderStore readStore)
{
    [Function(nameof(GetOrder))]
    public async Task<IActionResult> GetOrder(
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "orders/{orderId}")] HttpRequest req,
        string orderId,
        CancellationToken cancellationToken)
    {
        Order? order = await readStore.GetAsync(orderId, cancellationToken);
        return order is null ? new NotFoundResult() : new OkObjectResult(order);
    }
}
Enter fullscreen mode Exit fullscreen mode

CreateOrderFunction writes through SQL. GetOrderFunction reads from Cosmos. Same Function App, same IOrderStore interface, two implementations resolved by key.

Two breaking changes worth knowing

.NET 9 changed missing-key behaviour. Before .NET 9, [FromKeyedServices("xyz")] would silently fall back to an unkeyed IService registration if no "xyz" key existed. As of .NET 9, it throws InvalidOperationException at resolve time. That's an improvement (typos no longer succeed quietly) and another reason to keep keys as compile-time constants.

.NET 10 changed KeyedService.AnyKey semantics. Two related changes: GetKeyedService(provider, type, KeyedService.AnyKey) now throws instead of resolving an arbitrary registration, and GetKeyedServices(provider, type, KeyedService.AnyKey) no longer returns services that were themselves registered against AnyKey. If you have library code using AnyKey for "give me whatever's registered", check it before the .NET 10 upgrade.

.NET 10 also added FromKeyedServicesAttribute.LookupMode so a keyed service's transitive dependencies can inherit the parent's key automatically. Useful for keyed graphs (a KeyedDataProcessor whose inner KeyedConnection should match the outer key), but skip it on the first pass. Explicit keys are easier to read.

The full working code lives in ProjectOrganizationDemo/ in the companion repo: shared Core library, two Function Apps, both keyed implementations, and a local.settings.json.example for each app.

Folder structure that holds up at 30 functions

Microsoft's official .NET isolated-worker sample groups files by trigger:

samples/FunctionApp/
├── FunctionApp.csproj
├── Program.cs
├── host.json
├── local.settings.json
├── HttpTriggerSimple/
├── HttpTriggerWithBlobInput/
├── HttpTriggerWithCancellation/
├── HttpTriggerWithDependencyInjection/
├── HttpTriggerWithMultipleOutputBindings/
└── QueueTrigger/
Enter fullscreen mode Exit fullscreen mode

That's the actual tree of Azure/azure-functions-dotnet-worker/samples/FunctionApp. 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.

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

The convention this article uses (and the one the companion sample uses) is:

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
Enter fullscreen mode Exit fullscreen mode

Microsoft does not document this layout for C# isolated worker. It's a community convention, not Microsoft guidance. The closest official endorsement is the Node.js v4 model docs, which explicitly recommend a src/functions/ subfolder. The .NET isolated-worker docs are silent on subfolder shape.

Three rules I follow:

  1. Functions/ holds trigger classes only. Every file in Functions/ has a [Function] attribute. If a class has no trigger, it doesn't go here.
  2. App-specific code is local. Reusable code goes to Core. CreateOrderRequest is a DTO only the HTTP app sees, so it lives in OrderProcessor.Http/Models/. Order, the domain record both apps share, lives in OrderProcessor.Core/Models/.
  3. Infrastructure/ is for cross-cutting wiring, not business logic. 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 Services/ or Core. If no, Infrastructure/.

host.json and local.settings.json stay in the project root. The deployment payload is explicit that they sit next to the executable, peer to your code files. There is no clever way to move them.

When the project also runs in Aspire

If the Function App is part of a .NET Aspire orchestration, OpenTelemetry, health checks, and resilience defaults move to a separate *.ServiceDefaults project. The Aspire docs are explicit: "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." When you go Aspire, the Infrastructure/ folder mostly empties into ServiceDefaults, and Program.cs becomes one extra builder.AddServiceDefaults() call.

When to split, when to keep together

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

  • Independent scale needs. 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.
  • Independent deploy cadence. 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.
  • Different host.json. Two HTTP endpoints, one needs maxConcurrentRequests: 25, the other 200. There is no way to set this per function inside one app.
  • Credential boundary. 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.
  • Test code mixed with prod. The scalability guide 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.

Keep together when:

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

Two reasons that sound good and are on neither list:

  • Cold start. 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.
  • "It feels too big." Aesthetic discomfort with a 20-function project is not a constraint. Pick a doc-citable reason from the list above, or accept the discomfort.

Wrap-up

The split-or-keep decision belongs to your scaling, deploy, credential, and host.json 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 host.json settings are not.

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 Microsoft.Azure.Functions.Worker.* references) is what keeps those abstractions reusable across multiple Function Apps without dragging the worker SDK into every consumer.

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

Top comments (0)