DEV Community

Cover image for Azure Services as Aspire Resources: Service Bus, Storage, and Redis
Martin Oehlert
Martin Oehlert

Posted on

Azure Services as Aspire Resources: Service Bus, Storage, and Redis

A [ServiceBusTrigger("orders", Connection = "messaging")] attribute doesn't hold a connection string. It holds the name "messaging", and something has to resolve that name to a real connection in every environment the function runs in. Part 1 moved one connection (host storage) out of local.settings.json and into the AppHost. The question this part answers is whether the same move holds for the connections you actually choose: a Service Bus namespace, a second storage account, a Redis cache, three services that in a traditional Functions app are three strings a developer pastes per machine and hopes match production.

The connection string problem, widened

The companion sample from Part 1 had two Functions projects sharing one host-storage emulator. That covered the connection the runtime needs for its own bookkeeping, the one injected as AzureWebJobsStorage. It didn't cover the connections your code triggers on.

Part 2 adds a third worker, OrderProcessor.ServiceBus, that consumes a Service Bus queue, dedupes against Redis, and writes a receipt blob to a storage account that isn't host storage. That one function touches three connections you name yourself:

public sealed class ConfirmOrderFunction(
    ILogger<ConfirmOrderFunction> logger,
    OrderValidator validator,
    IConnectionMultiplexer redis)
{
    [Function(nameof(ConfirmOrder))]
    [BlobOutput("receipts/{OrderId}.json", Connection = "receipts")]
    public async Task<Order?> ConfirmOrder(
        [ServiceBusTrigger("orders", Connection = "messaging")] OrderMessage message,
        CancellationToken cancellationToken)
    {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

End-to-end path: a Service Bus message flows through the trigger, into the Redis idempotency gate, and out to a blob receipt; a duplicate delivery short-circuits to null.

In a traditional setup each of those (messaging, receipts, and the Redis connection the multiplexer reads) is a string in local.settings.json, copied from the portal, kept current by hand on every machine and in every environment. Three names, three places to drift. The fix is the one from Part 1, applied three times: declare each service once in the AppHost as a resource, hand it to the Functions project by reference, and let Aspire compute the connection value per environment. What stays is the short name. "messaging" appears in the AppHost and on the trigger, and those two have to agree. The value behind the name stops being something you maintain.

The rest of this article is those three declarations and what each one resolves to.

Service Bus as an Aspire resource

The package is Aspire.Hosting.Azure.ServiceBus. Two lines in AppHost.cs declare the namespace and a queue:

var messaging = builder.AddAzureServiceBus("messaging").RunAsEmulator();
messaging.AddServiceBusQueue("orders");
Enter fullscreen mode Exit fullscreen mode

AddAzureServiceBus("messaging") returns the namespace resource. Children come from AddServiceBusQueue / AddServiceBusTopic / AddServiceBusSubscription (the older AddQueue / AddTopic / AddSubscription names were obsoleted in 9.1, so a 9.x snippet from the Learn API reference won't compile against 13.x).

RunAsEmulator() is the line that earns the section. Locally it starts the Service Bus emulator as containers Aspire owns. In publish mode it's a no-op, so the same two lines provision a real namespace under azd. One declaration, two resolutions.

Two things about that emulator are worth knowing before you run it.

First, it isn't one container, it's two. The emulator needs a SQL backend, so Aspire pulls mcr.microsoft.com/azure-messaging/servicebus-emulator:2.0.0 plus mcr.microsoft.com/mssql/server:2022-latest, generates the SQL sa password, sets ACCEPT_EULA=Y on both, and injects the SQL connection into the emulator. You write one line; Aspire coordinates two containers and the secret between them. No .env file to edit.

Second, the emulator doesn't create entities at runtime. It reads a Config.json at startup and provisions exactly what's in it. Aspire generates that file from your AddServiceBusQueue("orders") declaration and mounts it, so the queue exists when the worker connects. On the live run the emulator logged Creating queue: orders then Emulator Service is Successfully Up!. If you need topics, subscriptions, or rules the emulator config supports but the fluent API doesn't reach, WithConfigurationFile and WithConfiguration are the escape hatches.

The trigger side is where Service Bus pays off the resource model, because Service Bus is one of the four integrations Aspire auto-wires into the Functions binding system. On the AppHost you hand the namespace to the worker by reference:

builder.AddAzureFunctionsProject<Projects.OrderProcessor_ServiceBus>("orders-sb")
    .WithHostStorage(hostStorage)
    .WithReference(messaging, "messaging");
Enter fullscreen mode Exit fullscreen mode

The second argument to WithReference is the connection name. The trigger names the same string:

[ServiceBusTrigger("orders", Connection = "messaging")] OrderMessage message
Enter fullscreen mode Exit fullscreen mode

That's the whole contract. Aspire computes the connection value (the emulator's local endpoint now, a real namespace under azd later) and injects it under messaging; the trigger resolves messaging and binds. The name matches because you typed it twice, not because anything derives it. Omit the second WithReference argument and it defaults to the resource name, which here is also messaging, so it would resolve either way; the explicit form is clearer about what the contract is. The worker needs Microsoft.Azure.Functions.Worker.Extensions.ServiceBus (5.24.0 against Worker 2.x) for the trigger attribute to exist.

Be honest with your team about the emulator's limits. Microsoft labels it dev/test only, production use is explicitly discouraged. It speaks AMQP over TCP, and it doesn't persist messages across a restart; entities re-provision from Config.json, but in-flight messages are gone. On Apple Silicon both images are amd64-only and run under Rosetta. That sounds like a caveat but it's the good kind: on the author's M-series machine the full message → trigger → blob path ran end to end with no special Docker flags beyond having Rosetta emulation available.

Storage beyond host storage

Part 1 used one storage resource for host bookkeeping. Application data shouldn't share it. The receipt this worker writes is your data, not the runtime's lease blobs and scaling state, so it gets its own resource:

var appStorage = builder.AddAzureStorage("app-storage").RunAsEmulator();
var receipts = appStorage.AddBlobs("receipts");
Enter fullscreen mode Exit fullscreen mode

RunAsEmulator() here is Azurite, pulled as mcr.microsoft.com/azure-storage/azurite:3.35.0 and started and stopped with the AppHost. There's no npm install -g azurite, no second terminal running azurite --silent, no hand-written devstoreaccount1 string. The prerequisite step from every Functions README becomes a line in a project the team checks in.

One storage resource fans out to all three services. AddBlobs and AddQueues give you the blob and queue endpoints; AddTables gives you the table service. Note the plural: there's no singular AddTable in 13.x, and Aspire 9.4 moved these to top-level calls on the storage resource (AddBlobContainer, Add*ServiceClient on the client side), so older snippets miss the rename. Blob and Queue auto-wire into triggers the same way Service Bus does; Tables do not, and need the WithEnvironment form the last section covers.

The blob output binding is auto-wired by the same WithReference pattern:

builder.AddAzureFunctionsProject<Projects.OrderProcessor_ServiceBus>("orders-sb")
    .WithHostStorage(hostStorage)
    .WithReference(messaging, "messaging")
    .WithReference(receipts, "receipts")
    .WithReference(cache);
Enter fullscreen mode Exit fullscreen mode

WithReference(receipts, "receipts") injects the connection the [BlobOutput(... Connection = "receipts")] attribute names. The function returns an Order, and Aspire serializes it to receipts/{OrderId}.json. The {OrderId} token in that path is worth a beat: it resolves from the Service Bus trigger's POCO, the OrderMessage that fired the function, not from any injected client. On the live run a message with OrderId w23-001 produced receipts/w23-001.json containing the serialized order. The blob binding read a value off the message that triggered it, across two different Azure services, with no glue code between them.

Ports are mapped dynamically, so never hardcode 127.0.0.1:10000; read the connection from the injected value. Azurite is in-memory by default. If you want receipts to survive a restart, WithDataVolume() or WithDataBindMount() opts into persistence.

Redis as an Aspire resource

Redis is the resource that breaks the pattern, and the break is the useful part of the section. The declaration looks like the others:

var cache = builder.AddRedis("cache");
Enter fullscreen mode Exit fullscreen mode

AddRedis (from Aspire.Hosting.Redis) runs a local Redis container with health checks; at publish it deploys containerized Redis on Container Apps. For a managed cache in production the current API is AddAzureManagedRedis (Azure Managed Redis, Entra ID auth by default). Don't reach for AddAzureRedis, PublishAsAzureRedis, or .RunAsContainer() on the Azure Redis resource; all three are [Obsolete] in 13.x.

The break is that Redis is not auto-wired into the Functions trigger system. The four integrations that feed triggers and bindings are Blob, Queue, Event Hubs, and Service Bus. Redis isn't one of them, which means WithReference(cache) on its own does not make a Redis trigger resolve. What WithReference(cache) does serve is the Aspire client integration: register IConnectionMultiplexer in the worker with one line and read the cache from code triggered by something else.

That's the path this sample uses. In Program.cs:

builder.AddRedisClient("cache");
Enter fullscreen mode Exit fullscreen mode

And the function takes IConnectionMultiplexer by constructor injection and uses Redis as an idempotency gate, because Service Bus delivers at-least-once and a retry can replay the same order:

var db = redis.GetDatabase();
var firstDelivery = await db.StringSetAsync(
    $"orders:seen:{message.OrderId}", "1", when: When.NotExists);
if (!firstDelivery)
{
    logger.LogWarning("Duplicate order {OrderId}, skipping receipt", message.OrderId);
    return null;
}
Enter fullscreen mode Exit fullscreen mode

One detail from the live run is worth a callout, because it's the kind of thing that costs an afternoon if you hit it raw. Aspire's 13.x Redis container ships with TLS and auth on by default: it runs redis-server --requirepass <generated> --tls-port 6379 ... with Aspire-issued certs. A naive redis-cli against it gets "Connection reset by peer" because it spoke plaintext to a TLS port. AddRedisClient("cache") handles the handshake and the generated password from the injected connection string, so the worker code stays at IConnectionMultiplexer and GetDatabase(). Hand-rolling the connection would mean configuring TLS and the secret yourself.

If you genuinely want to trigger on Redis (Pub/Sub, List, or Stream), the Microsoft.Azure.Functions.Worker.Extensions.Redis extension exists, but two things apply. The trigger attribute's first argument is an app-setting name, so you wire the connection with the WithEnvironment form from the next section rather than WithReference. And the Redis triggers run only on Elastic Premium or dedicated App Service plans, not Consumption or Flex Consumption. For the serverless default, the IConnectionMultiplexer-via-DI path above is the one that works on every plan.

How Aspire resolves dev versus production

Three declarations, three behaviors at the trigger boundary. The rule underneath them is exact, so state it exactly: Aspire auto-wires four integrations into the Functions binding system through WithReference, and those four are Blob Storage, Queue Storage, Event Hubs, and Service Bus. For one of those, WithReference(resource, "name") is the whole wiring. For anything else (Redis, Cosmos, SQL, Tables, a custom service), you set the env var yourself:

builder.AddAzureFunctionsProject<Projects.OrderProcessor_ServiceBus>("orders-sb")
    .WithEnvironment("RedisConnection", cache.Resource.ConnectionStringExpression);
Enter fullscreen mode Exit fullscreen mode

One line per non-auto-wired resource. Real, but bounded, and the same shape every time.

Aspire auto-wires four integrations (Service Bus, Blob, Queue, Event Hubs) into Functions bindings through WithReference; every other resource takes an explicit WithEnvironment line.

What the name buys you is single-sourcing of the connection value, not the name. WithReference(messaging, "messaging") and [ServiceBusTrigger(Connection = "messaging")] agree because you wrote "messaging" in both places. Aspire computes what that name resolves to and how that changes between local and published; it does not derive the name or check that the two spellings match. The one fully-automatic name in the whole system is AzureWebJobsStorage, injected by AddAzureFunctionsProject<T>() itself. Every other name is a contract you keep in code.

The resolution itself keys off execution context, not config files. RunAsEmulator() (Service Bus, Storage) and the local container (AddRedis) are what you get when you run the AppHost. Run azd in publish mode and RunAsEmulator() becomes a no-op, so the same declaration provisions the real Azure resource. There is no appsettings.Production.json toggling between them; the decision is whether you're running or publishing.

One RunAsEmulator declaration resolves two ways: local emulator containers when you run the AppHost, a provisioned Azure namespace when you publish with azd.

The connection's shape flips on publish, and this is the part that's easy to miss. Provisioned Azure resources default to identity-based connections, so a published Storage connection is a __serviceUri and a Service Bus connection is a __fullyQualifiedNamespace, not a key-bearing string. For the four auto-wired integrations Aspire emits the right suffix for you. For the escape-hatch resources you write that branch. A Tables service is the clean example: it's Storage, but not one of the auto-wired four, so locally the binding reads a connection string and on Azure it needs the identity-based service URI. You switch the env-var suffix on the execution context:

var ledger = appStorage.AddTables("ledger");

builder.AddAzureFunctionsProject<Projects.OrderProcessor_ServiceBus>("orders-sb")
    .WithEnvironment(
        builder.ExecutionContext.IsPublishMode ? "Ledger__serviceUri" : "Ledger",
        ledger.Resource.ConnectionStringExpression);
Enter fullscreen mode Exit fullscreen mode

Locally that injects the Azurite table endpoint under the name Ledger; on publish the name becomes Ledger__serviceUri pointing at the provisioned account, and the Azure SDK reads managed identity off the suffix. The auto-wired four run this same switch for you; for everything else, this one line is it.

The default publish target is Azure Container Apps, which is GA; publishing as a real Function App needs Aspire.Hosting.Azure.AppService, still preview as of May 2026. Part 3 takes the publish path apart.

Three resources, one source of truth

The migration from Part 1 is additive again. The AppHost gains four declarations (a Service Bus namespace and its queue, a second storage resource, a Redis cache) and three WithReference lines on one new Functions project. The worker gains one AddRedisClient("cache") call. Those few lines start five containers when you run the AppHost: two Azurite instances (host storage and app-storage), the Service Bus emulator with its mssql/server:2022-latest backend, and Redis 8.6. You declared three services; Aspire pulled the images, generated the secrets between them, and wired every connection.

No connection strings moved into local.settings.json, because that's the file the whole exercise is removing as a source of truth. Keep FUNCTIONS_WORKER_RUNTIME in it and let Aspire own the rest; if a value is set in both, Aspire wins. One line is worth deleting on the way out: the Functions template seeds AzureWebJobsStorage to UseDevelopmentStorage=true, which starts its own Azurite and can race the one Aspire owns. Remove it and let AddAzureFunctionsProject<T>() inject host storage instead.

The honest scope: the Service Bus emulator is dev/test only and runs under Rosetta on Apple Silicon, Redis isn't auto-wired and its trigger extension is Premium-plan only, and identity-based connections on publish need one branch for the resources outside the auto-wired four. None of that is a blocker, but a teammate hits each one eventually, so put them in the README, not the postmortem.

Part 3 takes this AppHost to Azure: what azd provisions, why Container Apps is the default target, and what Aspire generates under the hood.

Of these three, which do you configure by hand today, a Service Bus connection per environment, a second storage account, or a Redis cache, and which one would you move into the AppHost first?

Top comments (0)