Azure Service Bus in .NET
Reading Time: 7 minutes
The Problem
As systems grow, synchronous calls start creating avoidable coupling.
An HTTP API receives a request, then immediately needs to:
- send an email
- update a billing system
- notify another service
- trigger fulfillment
- publish an audit event
If every downstream action happens inline, the request path becomes fragile:
- one slow dependency slows the whole operation
- one temporary failure can fail the entire request
- retries become messy because callers do not know what already succeeded
- spikes in traffic hit every dependent service at the same time
- background work gets forced into the interactive path
This is where teams usually discover they do not just need communication. They need durable, reliable messaging.
The Solution
Azure Service Bus is a fully managed message broker for decoupling services and moving work out of the request path.
At a practical level, Service Bus gives you:
- queues for point-to-point work distribution
- topics and subscriptions for publish-subscribe fan-out
- dead-letter queues for messages that cannot be processed successfully
- duplicate detection to reduce accidental double submission
- sessions when you need ordered, related message processing
- scheduled delivery for delayed processing
The key shift is architectural: the sender submits a durable message and can move on. Receivers process that message independently and at their own pace.
Description
When Service Bus is a good fit
Service Bus is usually the right tool when you need more than a simple queue.
Good examples:
- order processing pipelines
- payment and fulfillment workflows
- integration between internal services
- background jobs that must survive restarts
- event fan-out where multiple consumers react to the same business event
If you need advanced messaging features such as dead-lettering, sessions, duplicate detection, or durable pub-sub, Service Bus is generally a better fit than a basic storage queue.
Queues vs topics
The simplest rule is:
- use a queue when exactly one consumer should handle the message
- use a topic when multiple consumers should receive their own copy
For example:
ordersqueue: one order processor handles each submitted orderorder-createdtopic: billing, analytics, and notification services each receive the same event through separate subscriptions
That distinction matters because it affects scaling, failure isolation, and how many downstream systems can evolve independently.
Delivery semantics and reliability
Service Bus is designed for at-least-once delivery, not exactly-once business processing.
That means your consumer code should be idempotent. If a handler sees the same message more than once, the outcome should still be correct.
The standard reliability pattern is:
- Receive the message in peek-lock mode.
- Run your business logic.
- Complete the message only after the work succeeds.
- Let Service Bus retry if processing fails.
- Inspect the dead-letter queue when messages exceed the allowed delivery count.
This is one of the most important design points in any messaging system. The broker helps with transport reliability. Your application still owns business correctness.
Installing the SDK packages
For current .NET applications, use the Azure SDK packages directly:
dotnet add package Azure.Messaging.ServiceBus --version 7.20.1
dotnet add package Azure.Identity --version 1.21.0
Azure.Messaging.ServiceBus is the main SDK. Azure.Identity gives you DefaultAzureCredential, which is the cleanest default for local development and managed identity in Azure.
Configuration
Avoid storing connection strings in application code. Prefer Microsoft Entra authentication and managed identity when the application is hosted in Azure.
{
"ServiceBus": {
"FullyQualifiedNamespace": "your-namespace.servicebus.windows.net",
"QueueName": "orders"
}
}
Options model
public sealed class ServiceBusOptions
{
public const string SectionName = "ServiceBus";
public string FullyQualifiedNamespace { get; set; } = string.Empty;
public string QueueName { get; set; } = string.Empty;
}
Registering ServiceBusClient
Like most Azure SDK clients, ServiceBusClient should be reused. Treat it as an application-level dependency, not something you create per request.
using Azure.Identity;
using Azure.Messaging.ServiceBus;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOptions<ServiceBusOptions>()
.Bind(builder.Configuration.GetSection(ServiceBusOptions.SectionName));
builder.Services.AddSingleton(provider =>
{
var options = provider
.GetRequiredService<IOptions<ServiceBusOptions>>()
.Value;
return new ServiceBusClient(
options.FullyQualifiedNamespace,
new DefaultAzureCredential());
});
builder.Services.AddSingleton<OrderMessagePublisher>();
builder.Services.AddHostedService<OrderMessageProcessor>();
This keeps authentication out of source control and lets the same code run locally with developer credentials and in Azure with managed identity.
Sending a message
Publish small, explicit payloads. Messages should describe work clearly and carry the metadata needed for safe retries and tracing.
using System.Text.Json;
using Azure.Messaging.ServiceBus;
using Microsoft.Extensions.Options;
public sealed record CreateOrderMessage(Guid OrderId, string CustomerId, decimal Total);
public sealed class OrderMessagePublisher : IAsyncDisposable
{
private readonly ServiceBusSender _sender;
public OrderMessagePublisher(
ServiceBusClient client,
IOptions<ServiceBusOptions> options)
{
_sender = client.CreateSender(options.Value.QueueName);
}
public async Task PublishAsync(
CreateOrderMessage orderMessage,
CancellationToken cancellationToken = default)
{
var body = JsonSerializer.Serialize(orderMessage);
var message = new ServiceBusMessage(body)
{
MessageId = orderMessage.OrderId.ToString(),
Subject = "order.created"
};
message.ApplicationProperties["customerId"] = orderMessage.CustomerId;
await _sender.SendMessageAsync(message, cancellationToken);
}
public ValueTask DisposeAsync() => _sender.DisposeAsync();
}
A few design choices matter here:
MessageIdhelps with duplicate detection scenariosSubjectgives you a lightweight event label- application properties make filtering and diagnostics easier without forcing full payload deserialization first
Processing messages with ServiceBusProcessor
For most worker-style consumers, ServiceBusProcessor is the most practical option. It handles the receive loop for you and gives you clear hooks for normal processing and error handling.
using System.Text.Json;
using Azure.Messaging.ServiceBus;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
public sealed class OrderMessageProcessor : BackgroundService, IAsyncDisposable
{
private readonly ServiceBusProcessor _processor;
private readonly ILogger<OrderMessageProcessor> _logger;
public OrderMessageProcessor(
ServiceBusClient client,
IOptions<ServiceBusOptions> options,
ILogger<OrderMessageProcessor> logger)
{
_logger = logger;
_processor = client.CreateProcessor(
options.Value.QueueName,
new ServiceBusProcessorOptions
{
AutoCompleteMessages = false,
MaxConcurrentCalls = 4
});
_processor.ProcessMessageAsync += ProcessMessageAsync;
_processor.ProcessErrorAsync += ProcessErrorAsync;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
=> _processor.StartProcessingAsync(stoppingToken);
private async Task ProcessMessageAsync(ProcessMessageEventArgs args)
{
var payload = args.Message.Body.ToString();
try
{
var message = JsonSerializer.Deserialize<CreateOrderMessage>(payload)
?? throw new InvalidOperationException("Message payload was null.");
_logger.LogInformation(
"Processing order {OrderId} for customer {CustomerId}",
message.OrderId,
message.CustomerId);
// Execute idempotent business logic here.
await args.CompleteMessageAsync(args.Message);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to process message {MessageId}",
args.Message.MessageId);
await args.DeadLetterMessageAsync(
args.Message,
deadLetterReason: ex.GetType().Name,
deadLetterErrorDescription: ex.Message);
}
}
private Task ProcessErrorAsync(ProcessErrorEventArgs args)
{
_logger.LogError(
args.Exception,
"Service Bus error. Entity: {EntityPath}, Source: {ErrorSource}",
args.EntityPath,
args.ErrorSource);
return Task.CompletedTask;
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await _processor.StopProcessingAsync(cancellationToken);
await base.StopAsync(cancellationToken);
}
public async ValueTask DisposeAsync()
{
await _processor.DisposeAsync();
}
}
The important setting above is AutoCompleteMessages = false. That makes completion explicit and keeps the success path honest. If your code has not really finished the work, it should not complete the message.
Dead-letter queues are part of the design
A dead-letter queue is not an edge case. It is part of the operating model.
Messages land in the DLQ when they cannot be processed successfully or when they exceed the maximum delivery count. Service Bus keeps them there until you explicitly inspect and remove or resubmit them.
That means production-ready systems should have:
- monitoring on dead-letter growth
- a triage process for poison messages
- enough payload and correlation data to understand what failed
- idempotent replay behavior when messages are resubmitted
Treat the DLQ as an operational tool, not a trash can.
Features that matter in real systems
Some Service Bus features are especially valuable once the system grows:
- sessions when message order matters for a related stream of work
- duplicate detection when send retries could create accidental duplicate messages
- scheduled messages when processing should start later
- topics and subscriptions when multiple bounded contexts need the same event
- dead-lettering when failures need containment and diagnosis instead of silent loss
You do not need every feature on day one. But Service Bus is attractive precisely because these capabilities are already there when the architecture needs them.
Summary
Azure Service Bus solves a real backend problem: how to move work between services reliably without forcing everything through a fragile synchronous path.
For .NET teams, the practical approach is simple:
- use queues for single-consumer work
- use topics for fan-out events
- authenticate with
DefaultAzureCredential - reuse
ServiceBusClient - process messages idempotently
- complete messages only after successful work
- treat the dead-letter queue as an operational signal
If you get those foundations right, Service Bus becomes one of the cleanest ways to decouple services without losing reliability.