Why this post exists
Most production problems in Azure Functions are not logic bugs. They are lifetime bugs: an HttpClient created on every call, a Singleton quietly holding a Scoped object, a "shared" service that is not actually shared. All of them come from the same gap, which is not knowing how dependency injection works inside a function app. This post closes that gap using one complete order-processing example.
In-process versus isolated worker
Think of a building. An in-process function app plugs into the host's own wiring. It shares the host's power supply, so it also shares the host's limits, including which .NET version and package versions it can use. An isolated worker app has its own power supply: it runs as a separate process, with its own Program.cs, its own dependency container and its own choice of .NET version.
Important date: Microsoft ends support for the in-process model on November 10, 2026. .NET 8 support also ends that date. Functions 4.x isolated supports .NET 10, 9, 8 and .NET Framework 4.8. For anything new, use the isolated worker model. Everything below assumes it.
The Program.cs of a Functions app
In the isolated model you write the entry point yourself. This is where dependency injection starts: you register services, the host builds the container once, and every function can ask for those services.
var host = new HostBuilder()
.ConfigureFunctionsWorkerDefaults()
.ConfigureServices((context, services) =>
{
services.AddApplicationInsightsTelemetryWorkerService();
services.ConfigureFunctionsApplicationInsights();
services.Configure<OrderProcessingOptions>(
context.Configuration.GetSection("OrderProcessing"));
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<INotificationSender, EmailNotificationSender>();
})
.Build();
host.Run();
Newer templates also offer a builder style that looks like an ASP.NET Core minimal app:
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<INotificationSender, EmailNotificationSender>();
builder.Build().Run();
Both do the same job. Use whichever your template generated.
Logging note: with Application Insights enabled in isolated mode, a default filter hides anything below Warning, so your LogInformation calls may not appear. Remove that rule:
.ConfigureLogging(logging =>
{
logging.Services.Configure<LoggerFilterOptions>(options =>
{
var rule = options.Rules.FirstOrDefault(r => r.ProviderName ==
"Microsoft.Extensions.Logging.ApplicationInsights.ApplicationInsightsLoggerProvider");
if (rule is not null) options.Rules.Remove(rule);
});
})
The example: processing orders from Service Bus
Orders arrive as JSON messages on a Service Bus queue called incoming-orders. The function validates the order, saves it, and sends a confirmation. Three things need injecting: a repository, a notification sender, and settings.
The model, options and interfaces
public record Order(string Id, string CustomerEmail, decimal Amount);
public class OrderProcessingOptions
{
public decimal MaxOrderAmount { get; set; } = 100000m;
}
public interface IOrderRepository
{
Task SaveAsync(Order order);
}
public interface INotificationSender
{
Task SendAsync(string recipient, string message);
}
The function class
public class ProcessOrderFunction
{
private static readonly JsonSerializerOptions JsonOptions =
new() { PropertyNameCaseInsensitive = true };
private readonly IOrderRepository _orderRepository;
private readonly INotificationSender _notificationSender;
private readonly OrderProcessingOptions _options;
private readonly ILogger<ProcessOrderFunction> _logger;
public ProcessOrderFunction(
IOrderRepository orderRepository,
INotificationSender notificationSender,
IOptions<OrderProcessingOptions> options,
ILogger<ProcessOrderFunction> logger)
{
_orderRepository = orderRepository;
_notificationSender = notificationSender;
_options = options.Value;
_logger = logger;
}
[Function("ProcessOrder")]
public async Task Run(
[ServiceBusTrigger("incoming-orders", Connection = "ServiceBusConnection")]
string message)
{
var order = JsonSerializer.Deserialize<Order>(message, JsonOptions)
?? throw new InvalidOperationException("Message body was not a valid order.");
if (order.Amount <= 0 || order.Amount > _options.MaxOrderAmount)
throw new InvalidOperationException($"Order {order.Id} amount is out of range.");
await _orderRepository.SaveAsync(order);
await _notificationSender.SendAsync(order.CustomerEmail, $"Order {order.Id} received.");
_logger.LogInformation("Processed order {OrderId}", order.Id);
}
}
Notice what the function does not do: it never creates a repository, a sender or a connection itself. It asks for them in the constructor. It logs the order id, not the customer email, so personal data stays out of your logs. Throwing on a bad order lets Service Bus retry and eventually dead-letter the message, which is the behaviour you want.
How dependencies get resolved
The power-outlet analogy: the function class is a lamp, the constructor is its plug, and the registrations in Program.cs are the wiring in the walls. The lamp never builds a power station. It plugs in. Step by step:
- Start: the worker process starts and runs Program.cs. Your registrations are collected in IServiceCollection, the blueprint.
- Build: Build() turns the blueprint into a working container. The power is on.
- A message arrives: the Functions host receives an order from incoming-orders and hands it to the worker.
- Scope created: the worker creates a new scope for this one invocation.
- Constructor resolved: the container sees ProcessOrderFunction needs an IOrderRepository, an INotificationSender, IOptions and ILogger. It builds each one, and anything those depend on, recursively.
- Run called: Run executes with the finished object. When it returns, the scope ends and Scoped services are disposed.
Lifetimes in Functions
Transient: a new instance every time something asks. Scoped: one instance per invocation. Singleton: one instance per host instance.
That last phrase is the one people miss. When your function app scales out, Azure runs several host instances. Each has its own container, so each has its own Singleton. In the outlet analogy: a Singleton is the main electrical panel of a building, but a scaled-out app is several buildings, each with its own panel. Never use a Singleton to hold state you expect to be shared across the whole app.
A sensible default: register repositories and senders as Scoped, stateless helpers as Singleton, and use AddHttpClient for HTTP.
The HttpClient mistake
Wrong version:
[Function("CallPartner")]
public async Task Run([ServiceBusTrigger("partner-calls")] string message)
{
using var client = new HttpClient(); // new socket every call
var response = await client.GetAsync("https://partner.example.com/api/status");
}
Issues:
- Every invocation opens new connections. Disposing the client does not release the sockets immediately, so under load you hit socket exhaustion.
- The base address and headers are hard-coded inside the function.
- It cannot be mocked in a unit test.
Right version:
services.AddHttpClient<IPartnerClient, PartnerClient>(client =>
client.BaseAddress = new Uri(context.Configuration["PartnerApi:BaseUrl"]!));
PartnerClient takes an HttpClient in its constructor, and the function takes an IPartnerClient. The factory manages connection pooling and lifetimes for you.
Captive dependency in Functions
A captive dependency is a long-lived service holding on to a short-lived one.
services.AddDbContext<OrderDbContext>(...); // Scoped
services.AddSingleton<IOrderRepository, OrderRepository>(); // MISTAKE
The Singleton repository captures the first DbContext it receives and keeps it for the life of the host instance. Functions can run several invocations concurrently on one host instance, so they now share one DbContext, which is not thread-safe. You get random, hard-to-reproduce errors. The fix: make the repository Scoped so it gets a fresh DbContext with every invocation.
Configuration and secrets
Locally, settings live in local.settings.json, which you never commit:
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"ServiceBusConnection__fullyQualifiedNamespace": "myns.servicebus.windows.net",
"OrderProcessing__MaxOrderAmount": "100000"
}
}
In Azure, the same names are Application Settings. The double underscore maps to the colon in configuration, so OrderProcessing_MaxOrderAmount becomes the OrderProcessing:MaxOrderAmount key that IOptions binds to. The ServiceBusConnection_fullyQualifiedNamespace form uses an identity-based connection, so the function authenticates with its Managed Identity instead of a connection string.
For genuine secrets, use a Key Vault reference as the value of an app setting:
@Microsoft.KeyVault(SecretUri=https://my-vault.vault.azure.net/secrets/PartnerApiKey/)
Your code just reads the setting. It never knows the secret lives in Key Vault.
Unit testing the function
This is the payoff. Because the function takes interfaces, a test can hand it mocks and run without Azure.
private static ProcessOrderFunction CreateFunction(
Mock<IOrderRepository> repo, Mock<INotificationSender> sender) =>
new(repo.Object, sender.Object,
Options.Create(new OrderProcessingOptions { MaxOrderAmount = 1000m }),
NullLogger<ProcessOrderFunction>.Instance);
[Fact]
public async Task Run_SavesOrderAndNotifies_WhenOrderIsValid()
{
var repo = new Mock<IOrderRepository>();
var sender = new Mock<INotificationSender>();
var function = CreateFunction(repo, sender);
await function.Run("""{"id":"ORD-1","customerEmail":"a@example.com","amount":49.99}""");
repo.Verify(r => r.SaveAsync(It.Is<Order>(o => o.Id == "ORD-1")), Times.Once);
sender.Verify(s => s.SendAsync("a@example.com", It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task Run_Throws_AndSavesNothing_WhenAmountExceedsMaximum()
{
var repo = new Mock<IOrderRepository>();
var sender = new Mock<INotificationSender>();
var function = CreateFunction(repo, sender);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
function.Run("""{"id":"ORD-2","customerEmail":"a@example.com","amount":5000}"""));
repo.Verify(r => r.SaveAsync(It.IsAny<Order>()), Times.Never);
sender.Verify(s => s.SendAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
Problem scenario and strategy
The problem: an inherited function builds its own SqlConnection, HttpClient and EmailSender inside Run. Under load it throws socket errors, it cannot be tested, and the connection string is hard-coded.
The strategy:
- Extract an interface for each dependency: repository, partner client, email sender.
- Register each in Program.cs with the correct lifetime: Scoped for data access, AddHttpClient for HTTP.
- Move credentials out of code: Managed Identity for Azure resources, Key Vault references for real secrets.
- Move tunable values into an options class bound with IOptions.
- Inject everything through the constructor.
- Write unit tests with mocks to lock in the behaviour.
- Redeploy and watch Application Insights: the socket errors disappear because connections are now pooled by the factory.
Key lessons
- Use the isolated worker model: in-process support ends Nov 10, 2026
- Program.cs is where registrations live, and the container is built once
- Scoped means per invocation; Singleton means per host instance, not per app
- Never new up HttpClient in a function: use AddHttpClient
- Never let a Singleton depend on a Scoped service
- Configuration binds through IOptions; secrets belong in Key Vault
- Interfaces make functions unit-testable without Azure
Summary
Dependency injection in Azure Functions is the same dependency injection you know from ASP.NET Core, with two differences worth remembering: Program.cs is your responsibility in the isolated model, and a Singleton lives per host instance. Get the lifetimes right, inject through constructors, keep secrets out of code, and your functions become reliable, configurable and testable.
More on these topics: Azure posts ยท C# posts
Top comments (0)