Azure Service Bus with .NET: Queues, Topics, and Reliable Messaging

When you need reliable, ordered, at-least-once message delivery between services, Azure Service Bus is the go-to choice in the Azure ecosystem. It sits above simpler offerings like Storage Queues, providing features like dead-lettering, sessions, scheduled delivery, and pub/sub via topics and subscriptions.

The Azure.Messaging.ServiceBus SDK (v7+) is the current library. If you're still on Microsoft.Azure.ServiceBus, it's time to migrate.

Client Registration

Register the ServiceBusClient as a singleton — it manages connections and is designed to be long-lived:

Example.cs
builder.Services.AddSingleton(new ServiceBusClient(
    builder.Configuration["ServiceBus:ConnectionString"]));

Or with managed identity:

Example.cs
builder.Services.AddSingleton(new ServiceBusClient(
    "mybus.servicebus.windows.net",
    new DefaultAzureCredential()));

Sending Messages

Create a sender for a specific queue or topic:

Example.cs
public class OrderPublisher
{
    private readonly ServiceBusSender _sender;

    public OrderPublisher(ServiceBusClient client)
    {
        _sender = client.CreateSender("orders");
    }

    public async Task PublishOrderCreatedAsync(OrderCreatedEvent evt)
    {
        var message = new ServiceBusMessage(
            BinaryData.FromObjectAsJson(evt))
        {
            ContentType = "application/json",
            Subject = "OrderCreated",
            MessageId = evt.OrderId.ToString(),
            CorrelationId = Activity.Current?.Id
        };

        await _sender.SendMessageAsync(message);
    }
}

Setting MessageId enables duplicate detection if your namespace has it configured. The Subject property is useful for filtering in topic subscriptions.

Batch Sending

When publishing multiple messages, use batches to reduce network round trips:

Example.cs
public async Task PublishBatchAsync(IEnumerable<OrderCreatedEvent> events)
{
    using var batch = await _sender.CreateMessageBatchAsync();

    foreach (var evt in events)
    {
        var message = new ServiceBusMessage(BinaryData.FromObjectAsJson(evt))
        {
            ContentType = "application/json",
            Subject = "OrderCreated"
        };

        if (!batch.TryAddMessage(message))
            throw new InvalidOperationException("Message too large for batch.");
    }

    await _sender.SendMessagesAsync(batch);
}

The batch respects the maximum message size. If TryAddMessage returns false, you need to send the current batch and start a new one.

Receiving Messages with the Processor

For continuous message processing, ServiceBusProcessor is the recommended approach:

Example.cs
public class OrderProcessor : IHostedService
{
    private readonly ServiceBusProcessor _processor;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OrderProcessor> _logger;

    public OrderProcessor(ServiceBusClient client, IServiceScopeFactory scopeFactory,
        ILogger<OrderProcessor> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;

        _processor = client.CreateProcessor("orders", new ServiceBusProcessorOptions
        {
            MaxConcurrentCalls = 5,
            AutoCompleteMessages = false,
            PrefetchCount = 10
        });

        _processor.ProcessMessageAsync += HandleMessageAsync;
        _processor.ProcessErrorAsync += HandleErrorAsync;
    }

    public Task StartAsync(CancellationToken ct) => _processor.StartProcessingAsync(ct);
    public Task StopAsync(CancellationToken ct) => _processor.StopProcessingAsync(ct);

    private async Task HandleMessageAsync(ProcessMessageEventArgs args)
    {
        await using var scope = _scopeFactory.CreateAsyncScope();
        var handler = scope.ServiceProvider.GetRequiredService<IOrderHandler>();

        var order = args.Message.Body.ToObjectFromJson<OrderCreatedEvent>();
        await handler.HandleAsync(order);

        await args.CompleteMessageAsync(args.Message);
    }

    private Task HandleErrorAsync(ProcessErrorEventArgs args)
    {
        _logger.LogError(args.Exception,
            "Service Bus error on {EntityPath}", args.EntityPath);
        return Task.CompletedTask;
    }
}

Key points:

Dead-Letter Handling

When a message can't be processed, dead-letter it with a reason:

Example.cs
await args.DeadLetterMessageAsync(args.Message,
    deadLetterReason: "InvalidPayload",
    deadLetterErrorDescription: "Order ID was missing from the message body.");

Build a separate process or function to drain the dead-letter queue periodically:

Example.cs
var dlqReceiver = client.CreateReceiver("orders",
    new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });

var messages = await dlqReceiver.ReceiveMessagesAsync(maxMessages: 10);

Topics and Subscriptions

For pub/sub, send to a topic and receive from subscriptions. The sending code is identical — just point the sender at a topic name. Subscriptions can filter messages using SQL-like rules or correlation filters:

Example.cs
// Receive from a filtered subscription
var processor = client.CreateProcessor(
    topicName: "order-events",
    subscriptionName: "billing-service",
    new ServiceBusProcessorOptions { MaxConcurrentCalls = 3 });

Filters are configured on the subscription itself, typically via Bicep, ARM, or the Azure portal. You can filter on Subject, application properties, or the message body.

Practical Advice

Service Bus is a foundational building block for distributed .NET systems on Azure. Get comfortable with these patterns and you'll handle most messaging scenarios cleanly.