# Azure Service Bus Topics, Subscription Filters, and Dead Letter Queues in .NET

> Go beyond basic queues in Azure Service Bus: publish to topics, route messages with SQL and correlation filters on the broker, and build a dead letter queue handling strategy in .NET.

Published: 2026-10-27. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/azure-service-bus-dotnet

**Azure Service Bus topics** publish an event to independent subscriptions, whose filters decide which messages each subscriber receives.
The .NET SDK supports manual settlement, dead-letter inspection, and duplicate detection.
These features simplify routing and recovery, but PeekLock consumers still need idempotency because failed or uncertain settlement can cause redelivery.

## When Should You Use a Topic?

Most Azure Service Bus tutorials stop at queues: send a message, process it, complete it.
I covered that ground in [**messaging made easy with Azure Service Bus**](https://milanjovanovic.tech/blog/messaging-made-easy-with-azure-service-bus), so I won't repeat it here.

This article is about the features you reach for once the basics work: **topics** for publish-subscribe, **subscription filters** for broker-side routing, and a strategy for **dead letter queues**.

If you're coming from [**RabbitMQ**](https://milanjovanovic.tech/blog/rabbitmq-dotnet-beginners-guide), the mapping is: topics are like fanout exchanges, subscriptions are like bound queues, and filters replace routing keys.

## Set Up the .NET Client

Use a Standard or Premium Service Bus namespace; the Basic tier does not support topics.
Create an `order-events` topic and an `inventory-service` subscription before starting the sample.
Provide `ConnectionStrings:ServiceBus` through your configuration provider with permissions for the operations being demonstrated; use workload identity and least-privilege roles in production.

```bash
dotnet new worker -n ServiceBusDemo --framework net10.0
cd ServiceBusDemo
dotnet add package Azure.Messaging.ServiceBus --version 7.21.0
```

Replace the generated worker registration in `Program.cs` with:

```csharp
using Azure.Messaging.ServiceBus;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton(_ => new ServiceBusClient(
    builder.Configuration.GetConnectionString("ServiceBus")
        ?? throw new InvalidOperationException("Missing ServiceBus connection string.")));
builder.Services.AddSingleton(sp => sp.GetRequiredService<ServiceBusClient>()
    .CreateSender("order-events"));
builder.Services.AddSingleton<EventPublisher>();
builder.Services.AddHostedService<InventoryEventProcessor>();
using var host = builder.Build();
await host.RunAsync();

public sealed record OrderPlaced(
    Guid EventId, Guid OrderId, long TotalAmount, string Region);
```

`TotalAmount` is an integer number of minor currency units in this example, so it is a supported AMQP property type and routing does not need floating-point money comparisons.
Keep `EventId` stable when retrying publication of the same event.
Place the classes below in separate files with `using Azure.Messaging.ServiceBus;` and `using System.Text.Json;`.
The worker template supplies hosting and logging imports.

## Topics and Subscriptions

A queue distributes delivery attempts among its consumers. A **topic** delivers a copy of each message to every **subscription**.

![A publisher sends to a topic that fans messages out to three subscriptions, each narrowed by its own filter: no filter, a SQL filter on TotalAmount, and a correlation filter on Region](https://milanjovanovic.tech/blogs/articles/azure-service-bus-dotnet/topic-subscriptions.png)

That distinction maps to intent:

- **Queue**: a command. "Process this order." One service is responsible.
- **Topic**: an event. "An order was placed." Inventory, notifications, and analytics all care, independently.

Publishing to a topic uses a sender for that topic, which is registered once and reused:

```csharp
public sealed class EventPublisher(ServiceBusSender sender)
{
    public Task PublishAsync(OrderPlaced order, CancellationToken ct = default)
    {
        var message = new ServiceBusMessage(JsonSerializer.SerializeToUtf8Bytes(order))
        {
            ContentType = "application/json",
            Subject = nameof(OrderPlaced),
            MessageId = order.EventId.ToString()
        };
        message.ApplicationProperties["TotalAmount"] = order.TotalAmount;
        message.ApplicationProperties["Region"] = order.Region;
        return sender.SendMessageAsync(message, ct);
    }
}
```

Each consuming service processes its own subscription with a `ServiceBusProcessor`:

```csharp
public sealed class InventoryEventProcessor(
    ServiceBusClient client,
    ILogger<InventoryEventProcessor> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await using var processor = client.CreateProcessor(
            "order-events", "inventory-service", new ServiceBusProcessorOptions
            {
                MaxConcurrentCalls = 5,
                AutoCompleteMessages = false
            });
        processor.ProcessMessageAsync += HandleMessageAsync;
        processor.ProcessErrorAsync += args =>
        {
            logger.LogError(args.Exception, "Service Bus error: {Source}", args.ErrorSource);
            return Task.CompletedTask;
        };

        await processor.StartProcessingAsync(stoppingToken);
        try
        {
            await Task.Delay(Timeout.Infinite, stoppingToken);
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
        finally
        {
            // Allow active callbacks to finish before disposing the processor.
            await processor.StopProcessingAsync(CancellationToken.None);
        }
    }

    private async Task HandleMessageAsync(ProcessMessageEventArgs args)
    {
        OrderPlaced order;
        try
        {
            order = JsonSerializer.Deserialize<OrderPlaced>(args.Message.Body)
                ?? throw new JsonException("Expected an order event.");
        }
        catch (JsonException)
        {
            await args.DeadLetterMessageAsync(args.Message,
                "InvalidJson", "Expected an OrderPlaced JSON object.", args.CancellationToken);
            return;
        }

        // Transport demonstration only. Commit idempotent business work here.
        logger.LogInformation("Inventory event for {OrderId}", order.OrderId);
        await args.CompleteMessageAsync(args.Message, args.CancellationToken);
    }
}
```

Exceptions from a handler are reported to `ProcessErrorAsync`; the processor abandons an unsettled message when the handler throws.
The example's only side effect is a log entry, so it does not pretend to update inventory.
When adding database work, create an async scope per delivery and commit an inbox record with the business transaction.

`DeadLetterMessageAsync` moves malformed JSON directly to the subscription DLQ.
Transient handler failures can be redelivered up to the entity's delivery limit.
A settlement failure can follow successful business work, so do not interpret every exception as proof that nothing happened.

## Subscription Filters

By default, every subscription receives every message published to the topic.
Filters change that: they're rules evaluated **on the broker**, so messages a subscription doesn't care about never cross the network.

### SQL Filters

SQL filters evaluate expressions against message properties:

```csharp
using Azure.Messaging.ServiceBus.Administration;

var connectionString = Environment.GetEnvironmentVariable("SERVICE_BUS_CONNECTION_STRING")
    ?? throw new InvalidOperationException("Missing administration connection string.");
var adminClient = new ServiceBusAdministrationClient(connectionString);

await adminClient.CreateSubscriptionAsync(
    new CreateSubscriptionOptions("order-events", "high-value-orders"),
    new CreateRuleOptions(
        "HighValueFilter",
        new SqlRuleFilter("TotalAmount > 1000")));
```

Only messages with an application property `TotalAmount` greater than 1000 reach this subscription.
The `EventPublisher` above sets `TotalAmount` and `Region` as application properties.
Filtering does not inspect the JSON body, so leaving those properties out changes routing even if the body contains the same fields.

### Correlation Filters

When your condition is a plain equality match, use a **correlation filter** instead.
Correlation filters have lower processing overhead than SQL filters for equality matches:

```csharp
await adminClient.CreateSubscriptionAsync(
    new CreateSubscriptionOptions("order-events", "eu-orders"),
    new CreateRuleOptions(
        "EuRegionFilter",
        new CorrelationRuleFilter
        {
            Subject = "OrderPlaced",
            ApplicationProperties = { ["Region"] = "EU" }
        }));
```

Use correlation filters for routing by event type or tenant, and SQL filters only when you need comparison operators.

### The Default Rule Gotcha

Creating a subscription without an explicit rule adds a `$Default` rule that matches everything.
The creation overloads above supply the intended rule atomically, avoiding that initial match-all window.
If you add your own rule without removing it, the subscription receives **all** messages anyway, because rules are OR-ed together:

```csharp
await adminClient.DeleteRuleAsync(
    "order-events", "high-value-orders", RuleProperties.DefaultRuleName);
```

Delete `$Default` only when that rule actually exists; the explicit-rule examples above do not create it.

## Dead Letter Queues

Every queue and every subscription has a built-in dead letter subqueue.
Messages land there for a handful of reasons:

- **MaxDeliveryCountExceeded**: the message was received and abandoned too many times (default max delivery count: 10)
- **TTLExpiredException**: the message expired and `DeadLetteringOnMessageExpiration` is enabled
- **Explicit dead-lettering**: your code called `DeadLetterMessageAsync` for a poison message
- **Filter evaluation errors**: a SQL filter failed and `EnableDeadLetteringOnFilterEvaluationExceptions` is enabled

I went deeper on the pattern itself in [**dead letter queues: handling failed messages**](https://milanjovanovic.tech/blog/dead-letter-queues-failed-messages); here's the Service Bus specific mechanics.

### Processing the DLQ

Use a peek operation to inspect failures without removing them:

```csharp
await using var receiver = client.CreateReceiver(
    "order-events", "inventory-service",
    new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });

foreach (var message in await receiver.PeekMessagesAsync(10))
{
    Console.WriteLine($"{message.MessageId}: {message.DeadLetterReason}");
}
```

Here `client` is the registered `ServiceBusClient`, or one constructed from your connection string in a separate console tool.
Peeked messages cannot be settled.
For replay, receive using PeekLock and complete only after durable archival or a successful resend.
Every subscription has its own DLQ, so monitor each subscription rather than only the topic.

### Resubmitting Messages

There's no built-in "retry from DLQ" button in the SDK. Resubmission means copying the message back to the main entity:

```csharp
static async Task ResubmitAsync(
    ServiceBusReceivedMessage deadLettered,
    ServiceBusSender sender, CancellationToken ct = default)
{
    var retryCount = deadLettered.ApplicationProperties
        .TryGetValue("ResubmitCount", out var count) ? Convert.ToInt32(count) : 0;

    if (retryCount >= 3)
    {
        throw new InvalidOperationException("Replay limit reached; leave the DLQ message for review.");
    }

    var replayKey = $"{deadLettered.MessageId}:replay:{retryCount + 1}";
    var replayHash = System.Security.Cryptography.SHA256.HashData(
        System.Text.Encoding.UTF8.GetBytes(replayKey));
    var clone = new ServiceBusMessage(deadLettered)
    {
        MessageId = Convert.ToHexString(replayHash)
    };
    clone.ApplicationProperties["ResubmitCount"] = retryCount + 1;

    await sender.SendMessageAsync(clone, ct);
}
```

The `ServiceBusMessage(ServiceBusReceivedMessage)` constructor copies the body and properties for exactly this scenario.
Complete the received DLQ message only after this helper succeeds.
The deterministic, bounded-length replay ID avoids reusing the original transport ID, which duplicate detection could otherwise silently discard; keep the original business event ID in the body for consumer idempotency.
Sending and completing are separate operations here, so a crash can still cause a duplicate.
Resending to the topic broadcasts again to every matching subscription, including ones that already succeeded; use an approved repair route when replay should target only one subscriber.

## Supporting Features Worth Enabling

These smaller features round out a production topic setup.

**Duplicate detection**: set a stable `MessageId` when publishing, and enable duplicate detection on the entity. Service Bus then drops messages with a repeated `MessageId` inside the detection window. This gives you effective at-most-once publishing from retrying producers. Pair it with idempotent handlers on the consumer side; the [**idempotent consumer pattern**](https://milanjovanovic.tech/blog/the-idempotent-consumer-pattern-in-dotnet-and-why-you-need-it) explains why you still need both.

**Sessions**: set `SessionId` to the business key when messages for one aggregate must be processed sequentially. **Azure Service Bus sessions in .NET** covers session-aware processors, lock renewal, and the head-of-line tradeoff.

**Scheduled messages**: publish now, deliver later.

```csharp
var sequenceNumber = await sender.ScheduleMessageAsync(
    message,
    DateTimeOffset.UtcNow.AddMinutes(30));

// Cancel before delivery if needed
await sender.CancelScheduledMessageAsync(sequenceNumber);
```

**Batch sending**: for high-throughput publishing, `CreateMessageBatchAsync` and `TryAddMessage` respect the broker's size limits and cut round-trips:

```csharp
static async Task SendBatchesAsync(
    ServiceBusSender sender,
    IEnumerable<ServiceBusMessage> messages,
    CancellationToken ct = default)
{
    using var enumerator = messages.GetEnumerator();
    var hasNext = enumerator.MoveNext();

    while (hasNext)
    {
        using var batch = await sender.CreateMessageBatchAsync(ct);
        while (hasNext && batch.TryAddMessage(enumerator.Current))
            hasNext = enumerator.MoveNext();

        if (batch.Count == 0)
            throw new InvalidOperationException("A single message exceeds the batch limit.");

        await sender.SendMessagesAsync(batch, ct);
    }
}
```

When a batch fills, the current message stays on the enumerator and is tried in the next batch.
The helper sends every batch once and rejects a message that cannot fit by itself.

## Summary

Use queues for commands and topic subscriptions for independent event consumers.
Prefer correlation filters over SQL filters, remove the default rule when narrowing a subscription, and monitor each subscription's DLQ separately.
Duplicate detection reduces repeated sends, but idempotent consumers still protect the processing side of at-least-once delivery.

## Frequently asked questions

### What is the difference between a queue and a topic in Azure Service Bus?

A queue is point-to-point: each delivery attempt goes to one receiver, with possible redelivery. A topic fans out: every subscription gets its own copy of each published message, optionally narrowed down by filters. Use queues for commands and topics for events.

### How do subscription filters work in Azure Service Bus?

Filters are rules evaluated on the broker when a message is published to a topic. Only matching messages land in the subscription. Correlation filters match properties by equality and are cheap; SQL filters support expressions like TotalAmount > 1000 at slightly higher cost.

### Why do messages end up in the Azure Service Bus dead letter queue?

The most common reasons: the delivery count exceeded MaxDeliveryCount after repeated processing failures, the message TTL expired with expiration dead-lettering enabled, a filter evaluation error occurred with filter-error dead-lettering enabled, or your code explicitly dead-lettered it as unprocessable.

### How do I reprocess dead-lettered messages in Azure Service Bus?

Read from the subqueue (queue/$deadletterqueue or the subscription equivalent), inspect DeadLetterReason, fix the underlying cause, and resend a copy of the message to the original queue or topic. There is no built-in requeue; the resubmit is your code.

### Does a topic subscription have its own dead letter queue?

Yes. Every queue and every individual subscription has its own dead letter subqueue. With many subscriptions you need DLQ monitoring per subscription, not just per topic.
