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:
builder.Services.AddSingleton(new ServiceBusClient(
builder.Configuration["ServiceBus:ConnectionString"]));
Or with managed identity:
builder.Services.AddSingleton(new ServiceBusClient(
"mybus.servicebus.windows.net",
new DefaultAzureCredential()));
Sending Messages
Create a sender for a specific queue or topic:
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:
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:
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:
- Set
AutoCompleteMessages = falseand explicitly callCompleteMessageAsyncafter successful processing. This ensures messages are only removed from the queue when you've actually handled them. - Create a DI scope per message to get scoped services like
DbContext. MaxConcurrentCallscontrols parallelism. Tune it based on your downstream capacity.
Dead-Letter Handling
When a message can't be processed, dead-letter it with a reason:
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:
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:
// 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
- Idempotency is your responsibility. Even with duplicate detection, design handlers to be safe to run twice.
- Use sessions when you need ordered processing per entity (e.g., all messages for the same order processed sequentially).
- Monitor dead-letter queues. Unattended dead-letter queues are a silent failure mode.
- Set reasonable lock durations. The default 30 seconds is fine for fast handlers, but long-running processing may need lock renewal via
RenewMessageLockAsync.
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.