Skip to main content

Running multiple NServiceBus endpoints in Azure Functions

This post is part of the NServiceBus 10 Improvements series. (See more)

An Azure Functions party showing many celebrating dogs, representing multiple endpoints running within a single Azure Functions app
An Azure Functions party, inspired by the "dog party" at the end of P.D. Eastman's Go, Dog, Go!

Azure Functions makes it easy to deploy multiple triggers side by side. With support for multiple NServiceBus endpoints in a single process, the new Functions integration brings that same flexibility to message processing: related endpoints can run in one Functions app without sharing handlers, configuration, or dependency injection registrations.

You can now make deployment much easier while preserving endpoint boundaries. HTTP and timer triggers can also send messages through named send-only endpoints without requiring a companion host.

That sounds like a party to me! Plus, each endpoint arrives with a “plus one”: the Service Bus trigger that feeds it messages. Here’s how we made room for them all in one Azure Functions app.

🔗Why one endpoint wasn’t enough

The original integration optimized for the common configuration of one NServiceBus endpoint per Functions app. But customers told us that as their systems grew, they needed to deploy additional endpoints without adding more deployment complexity.

In one of our own systems, an HTTP trigger needed to notify four independent subscribers. The single-endpoint model meant choosing between sending four commands in a single app and deploying four apps to use publish/subscribe. Supporting multiple endpoints adds a better option: publish one event, preserve each subscriber’s endpoint boundary, and deploy them all together.

The previous package 1 focused on getting you set up with an NServiceBus endpoint as easily as possible, generating the ServiceBus trigger that would have been a bit foreign to an existing NServiceBus developer at the time.

The new model starts with ordinary Azure Functions code. You still use the Azure Functions SDK attributes to describe the trigger. NServiceBus adds its own attribute and source generator to connect that trigger to an endpoint pipeline, where the same handlers, behaviors, and features used by other NServiceBus endpoints can run.

public partial class SalesEndpoint
{
    [Function("Sales")]
    [NServiceBusFunction]
    public partial Task Sales(
        [ServiceBusTrigger("sales", AutoCompleteMessages = false)]
        ServiceBusReceivedMessage message,
        ServiceBusMessageActions messageActions,
        FunctionContext functionContext,
        CancellationToken cancellationToken = default);

    public static void ConfigureSales(EndpointConfiguration configuration, IServiceCollection services)
    {
        // Hidden: configure transport, persistence, serialization, etc.

        // Configure dependency injection services for this endpoint
        services.AddSingleton(new MyComponent("Sales"));

        // Register handlers owned by this endpoint
        configuration.AddHandler<SubmitOrderHandler>();
    }
}

The [Function] and [ServiceBusTrigger] attributes are still from the Azure Functions contract. They tell the Functions runtime which queue to listen to and how to bind the incoming ServiceBusReceivedMessage.

The [NServiceBusFunction] attribute tells the NServiceBus source generator to emit the method body for the partial function. That generated body takes the message delivered by the Functions runtime and passes it into the correct NServiceBus endpoint pipeline.

The configuration method keeps the endpoint configuration close to the trigger. In the example, the Sales endpoint registers only the SubmitOrderHandler and a service the handler requires. There is no assembly-wide guessing required for the endpoint to know what belongs to it.

That is the important part. The programming model is still familiar Functions code, but the endpoint boundary is explicit. A developer reading the function can see the Azure Functions trigger and the NServiceBus endpoint shape in one place.

🔗Register endpoints explicitly

In the new spirit of explicit registration, we couldn’t fall back on assembly scanning to wire up these endpoints.

Instead, you call an explicit method to add the NServiceBusFunction endpoints to the Functions app:

var builder = FunctionsApplication.CreateBuilder(args);

builder.AddNServiceBusFunctions();

The implementation of this method, however, is emitted by a source generator that finds all the [NServiceBusFunction] attributes at compile time and wires up the EndpointConfiguration with its associated Functions trigger.

🔗Keep endpoints separate inside one app

Because endpoint boundaries no longer depend on process ownership, a Function app can host multiple endpoints. Of course, you could define a separate class for each endpoint you want to define, but you can also combine multiple endpoints in a single class if it makes sense:

public partial class BillingFunctions
{
    [Function("BillingApi")]
    [NServiceBusFunction]
    public partial Task BillingApi(
        [ServiceBusTrigger("billing-api", AutoCompleteMessages = false)]
        ServiceBusReceivedMessage message,
        ServiceBusMessageActions messageActions,
        FunctionContext functionContext,
        CancellationToken cancellationToken = default);

    public static void ConfigureBillingApi(EndpointConfiguration configuration)
    {
        // Hidden: configure transport, persistence, serialization, etc.

        configuration.AddHandler<ProcessPaymentHandler>();
    }

    [Function("BillingBackend")]
    [NServiceBusFunction]
    public partial Task BillingBackend(
        [ServiceBusTrigger("billing-backend", AutoCompleteMessages = false)]
        ServiceBusReceivedMessage message,
        ServiceBusMessageActions messageActions,
        FunctionContext functionContext,
        CancellationToken cancellationToken = default);

    public static void ConfigureBillingBackend(
        EndpointConfiguration endpointConfiguration,
        IConfiguration configuration,
        IHostEnvironment environment)
    {
        // Hidden: configure transport, persistence, serialization, etc.

        // Vary configuration based on injected IHostEnvironment
        if (environment.IsProduction())
        {
            endpointConfiguration.AuditProcessedMessagesTo(configuration["audit-queue"] ?? "audit");
        }
    }
}

These are not two triggers sharing one large endpoint. They are two endpoints hosted by the same Function app. Each one gets its own pipeline, configuration, and service graph. A handler registered for the API endpoint does not accidentally become part of the backend endpoint. A production-only audit setting for one endpoint does not leak into the other.

That makes the model useful for more than demos. You can keep related endpoints in a single deployment unit when that reduces operational friction, while still preserving endpoint-level boundaries that make message-driven systems manageable.

🔗Send messages from other trigger types

Many Function apps are not only message processors. An app might expose an HttpTrigger that needs to send commands or publish events.

The new model supports send-only endpoints in the same app. The code is similar to full endpoints, except without the Functions trigger. In this case, an attribute decorates the endpoint configuration method itself:

[NServiceBusSendOnlyFunction("client")]
public static void ConfigureClient(EndpointConfiguration endpointConfiguration, IServiceCollection services)
{
    services.AddSingleton(new MyComponent("client"));

    var transport = new AzureServiceBusServerlessTransport(TopicTopology.Default);
    var routing = endpointConfiguration.UseTransport(transport);

    routing.RouteToEndpoint(typeof(SubmitOrder), "sales");
    endpointConfiguration.UseSerialization<SystemJsonSerializer>();
}

An HttpTrigger can then receive the keyed IMessageSession and send a command:

class SalesApi([FromKeyedServices("client")] IMessageSession session)
{
    [Function("SalesApi")]
    public async Task<HttpResponseData> Api(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData request,
        CancellationToken cancellationToken)
    {
        await session.Send(new SubmitOrder(), cancellationToken);

        var response = request.CreateResponse(HttpStatusCode.Accepted);
        await response.WriteStringAsync("Order submitted.", cancellationToken);
        return response;
    }
}

That yields a compact yet well-separated shape: HTTP functions accept work which is routed through send-only endpoints that dispatch messages, and then to Service Bus-triggered endpoints which process them. Message processors and transactional session scenarios can run entirely inside the Function app instead of requiring a companion host to provide an NServiceBus session.

And of course, you can use a send-only endpoint in a timer trigger or any of the other supported trigger types, enabling a lot of integration scenarios that translate patterns of “technical events” from Kafka, Event Hubs, or one of the Azure services like Blob Storage into business events published through Azure Service Bus messages and processed via ServiceBus triggers.

The result is not a shared global bus hidden inside the Functions runtime. It is a set of named endpoint services with explicit configuration and clear dependency injection boundaries. If the deployment topology must change in the future, with either the HTTP entry point or message processor moving outside the Functions app, the message contracts and endpoint behavior can remain unchanged.

🔗The compiler catches the easy mistakes

Source generation only works well when the compiler can tell you what went wrong. The new integration includes analyzers for common mistakes that are hard to spot when reading a Function method signature.

If a function method is not partial, the generator cannot provide the body. If AutoCompleteMessages is left on, the Functions runtime may complete the message before the NServiceBus pipeline has made its recoverability decision.

Those are build-time problems now. The analyzer flags code that needs to change before you even run the app for the first time.

Source generation only works well when the compiler can tell you what went wrong. The new integration includes analyzers for common mistakes that are otherwise hard to spot.

As an example, NServiceBus needs the trigger’s AutoCompleteMessages property to be false so that the Functions runtime doesn’t complete the message before the NServiceBus pipeline has determined whether processing was successful. Instead of surfacing as a runtime exception, this becomes a build-time problem. The analyzer flags code that needs to change with red squiggles before you even run the app for the first time.

We’ve found this pattern of providing compile-time feedback to be very helpful. No more fixing one runtime exception after another until you get it right, with long builds in between. We call this exception-driven development. Instead, you get instant feedback before you even compile, and can get to the code that matters more quickly.

🔗Summary

What makes Azure Functions attractive is how easily we can deploy multiple triggers side by side. Our new Azure Functions integration delivers that for message processing, allowing multiple NServiceBus endpoints while retaining isolation between those endpoints.

The work we described in this series of posts made that possible.

We redesigned assembly scanning to break up the single-endpoint limitation and prepare for future support for assembly trimming and ahead-of-time compilation, 2 and we created a new hosting model to host multiple NServiceBus endpoints. Along the way, we got a pipeline redesign and convention-based handlers as part of the fun.

And, finally, we have multiple endpoints in Azure Functions apps.

You can keep one endpoint per Functions app if you prefer a party of one, or invite related endpoints into a single app to reduce operational overhead without sacrificing endpoint isolation.

So, take a look at the new Azure Functions hosting documentation, because it might be a great time to build something new. Or, if you have an existing Azure Functions endpoint, check out the migration guide. (We pointed a coding agent at the guide, and then it was able to migrate our in-house project for us.)

Welcome to the party.

Share on Twitter

About the author

Daniel Marbach

Daniel Marbach is a core engineer who is also the life of the party. When you arrive, you will find him passing out Swiss chocolates next to the paper hats.

More from the NServiceBus 10 Improvements series:

  1. …which included the name "Worker" because the "isolated worker" model replaced the earlier "in-process" hosting model that has been retired by Microsoft. Creating a new package also allowed us to unify the naming around Functions now that the distinction between in-process and isolated worker is no longer necessary.

  2. One day, we believe ahead-of-time compilation will be a killer feature for Azure Functions hosting, as serverless environments start instances on demand, so cold start time and total memory footprint are key. The thing is that the Azure Functions SDK doesn't support trimming or native AOT yet.

Don't miss a thing. Sign up today and we'll send you an email when new posts come out.
Thank you for subscribing. We'll be in touch soon.
 
We collect and use this information in accordance with our privacy policy.