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

distributed-systemsdotnet
Azure Service Bus Topics, Subscription Filters, and Dead Letter Queues in .NET

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, 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, 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.

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:

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

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:

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:

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:

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:

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:

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; here's the Service Bus specific mechanics.

Processing the DLQ

Use a peek operation to inspect failures without removing them:

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:

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 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.

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:

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.

  • Amazon SQS With .NET: A Practical Guide

    Amazon SQS removes the broker from your operations burden, but reliable consumers still depend on visibility timeouts, explicit deletes, long polling, and a dead letter policy. This guide builds that baseline in .NET with the AWS SDK, FIFO queues, batch operations, and MassTransit.

  • Backpressure in .NET: Handling Producers That Outrun Consumers

    Backpressure slows producers when consumers cannot keep up. Use bounded .NET channels to wait for capacity, drop replaceable updates, or reject excess requests with 503. The examples distinguish admission to memory from durable processing and show how to size a queue for a burst.

  • The CAP Theorem for Working Developers

    During a network partition, distributed systems cannot guarantee both linearizable operations and availability to every non-failed node. Learn how that limit affects PostgreSQL replication, read routing, and the guarantees your .NET application needs for each operation.

Browse all Distributed Systems guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.