← Go back

.NET Aspire - Start resource on servicebus message


I’m becoming a fan of .NET Aspire. It’s a great set of tools that makes it easy to locally debug a platform made up of multiple frontends, background services, and an API — all with the push of a button. While I know that Aspire has options for doing more than just helping with debugging, I’m sticking to that for now.

With the .NET Aspire 9.4 release, it feels like Microsoft is opening up the tool’s internal mechanics. This version introduces ResourceCommandService, an API for executing commands against resources. You can now programmatically execute the same commands that appear in the Aspire dashboard.

The application I’m currently working on includes a long-running console application deployed as an Azure Container App Job. It’s triggered whenever a new message is published to a Service Bus.

Previously, I tried to mimic this setup locally but failed due to the lack of an API to start resources on demand. I even asked David Fowler (Engineer @ .NET Aspire) about it, but I never got a response, so I eventually let the idea rest…

… Until now!

WithStartOnMessage();

To solve this, I created a custom extension method called WithStartOnMessage(). The idea is simple: it monitors a Service Bus queue and, upon finding a new message, it programmatically starts a specific project resource. This bridges the gap in my local development setup, making it behave just like my production environment in Azure.

Let’s dive into how it works. The magic happens in two parts: the extension method and a background service it registers.

Extension method

The extension method adds the start on message behavior to a resource.

public static IResourceBuilder<T> WithStartOnMessage<T>(
        this IResourceBuilder<T> resourceBuilder,
        IResourceBuilder<AzureServiceBusQueueResource> queueResource
    ) where T : IResourceWithWaitSupport
{
    resourceBuilder
        .WaitFor(queueResource)
        .WithExplicitStart();

    resourceBuilder.ApplicationBuilder.Services.AddHostedService(sp => 
        new StartOnMessageBackgroundService<T>(
            resourceBuilder.Resource,
            queueResource.Resource,
            sp.GetRequiredService<ResourceCommandService>()));

    return resourceBuilder;
}  

The method is does the following three things:

  1. WithExplicitStart(): This tells the Aspire host not to start the console application automatically. It will wait for an explicit command.
  2. WaitFor(queueResource): This sets up a dependency, ensuring the Service Bus queue is available before our project can be started.
  3. AddHostedService(...): This registers our custom StartOnMessageBackgroundService to run as a background service. This service contains the logic for polling the queue and starting the resource.

Background service

The StartOnMessageBackgroundService is where the real work happens. It runs continuously in the background, checking for new messages.

private class StartOnMessageBackgroundService<T>(
        T startResource,
        AzureServiceBusQueueResource queueResource,
        ResourceCommandService commandService
    ) : BackgroundService where T : IResourceWithWaitSupport
{
    private long _lastSequenceNumber;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Get the connection string managed by Aspire
        var serviceBusConnectionString = await queueResource
            .ConnectionStringExpression
            .GetValueAsync(stoppingToken);

        if (string.IsNullOrEmpty(serviceBusConnectionString))
        {
            return;
        }
        
        await using var client = new ServiceBusClient(serviceBusConnectionString);
        await using var receiver = client.CreateReceiver(queueResource.QueueName);
        
        // Poll the queue every 5 seconds
        using PeriodicTimer timer = new(TimeSpan.FromSeconds(5));
        
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            // Peek at new messages without removing them from the queue
            var messages = await receiver
                .PeekMessagesAsync(int.MaxValue, _lastSequenceNumber + 1, stoppingToken);

            foreach (var serviceBusReceivedMessage in messages)
            {
                // The magic! Start the resource using the new API.
                await commandService.ExecuteCommandAsync(startResource, "resource-start", stoppingToken);
                _lastSequenceNumber = serviceBusReceivedMessage.SequenceNumber;
            }
        }
    }
}

This service uses a PeriodicTimer to poll the queue every five seconds. The key here is the use of PeekMessagesAsync. This method looks at messages without actually “receiving” or removing them from the queue. This is perfect because we want the actual consumer (our ConsoleApp) to process the message, not the Aspire host.

For every new message it peeks at, it uses the new ResourceCommandService to execute the resource-start command on our console app. We also keep track of the _lastSequenceNumber to ensure we don’t try to start the job again for a message we’ve already seen.

Tying It All Together

With the extension method in place, the Program.cs in my Aspire host becomes as follows:

using AspireStartOnServicebusMessage.AppHost;
using Projects;

var builder = DistributedApplication.CreateBuilder(args);

// Set up the Service Bus emulator and a queue
var queueResource = builder
    .AddAzureServiceBus("servicebus")
    .RunAsEmulator()
    .AddServiceBusQueue("queued");

// Define the console app and tell it to start on a message
builder
    .AddProject<ConsoleApp>("console-app")
    .WithStartOnMessage(queueResource);

builder.Build().Run();

Now, when I run my Aspire application:

  1. The Service Bus emulator starts.
  2. The console-app is loaded but remains in a “Waiting” state.
  3. When I publish a message to the queue, the StartOnMessageBackgroundService detects it.
  4. The service calls ExecuteCommandAsync, and the console-app starts!

This small addition, powered by the new ResourceCommandService, has made my local development more aligned with production.

Navigate to home