Problem Context

Most distributed systems start with HTTP everywhere. It works until one downstream gets slow and everyone times out, until one consumer crashes and loses an event, until you need ordering, fan-out, or scheduled delivery and find that "just retry" doesn't cut it. That's when teams reach for a broker.

Azure Service Bus is a fully managed enterprise message broker built for transactional workflows. It gives you queues for point-to-point work, topics + subscriptions for fan-out, sessions for ordering, scheduled and deferred messages, dead-letter queues, AMQP 1.0 wire protocol, geo-DR, and Entra-ID auth. This guide covers the patterns and gotchas that bite in production.

๐Ÿค” Sound familiar?
  • Your "at-least-once" processor is actually "exactly twice" because nobody's idempotent
  • You used ReceiveAndDelete mode and lost messages on a crash
  • Your dead-letter queue is full and you can't remember when last someone looked at it
  • You confused Service Bus with Event Hubs or Event Grid and picked the wrong one

This is Service Bus the way production teams configure it โ€” peek-lock, sessions, DLQ replay, RBAC, and the choice between Standard, Premium, and the new partitioned Premium.

Concept Explanation

Two primitives:

  • Queue โ€” point-to-point. One sender, one or more competing consumers. Each message delivered to exactly one consumer.
  • Topic + Subscription โ€” pub/sub. One sender, N independent subscriptions. Each subscription gets its own copy with its own filter.

flowchart LR
    P["Producer"] --> T["Topic: orders"]
    T -->|filter: type='paid'| S1["Sub: billing"]
    T -->|filter: type='paid'| S2["Sub: warehouse"]
    T -->|filter: amount > 1000| S3["Sub: fraud"]
    S1 --> C1["Billing Worker"]
    S2 --> C2["Warehouse Worker"]
    S3 --> C3["Fraud Worker"]
    C1 -.->|MaxDeliveryCount<br/>exceeded| DLQ["Dead-letter Queue"]

    style T fill:#0078D4,color:#fff,stroke:#005a9e
    style DLQ fill:#dc2626,color:#fff,stroke:#b91c1c

Three SKUs in 2026:

  • Basicโ€” queues only. Don't use in production.
  • Standard โ€” queues + topics, dynamic shared capacity, AMQP. Fine for most apps.
  • Premium โ€” dedicated capacity, low jitter, geo-DR, partitioned namespaces, large messages (up to 100 MB), VNet integration. Required for high-throughput / regulated workloads.

Implementation

Step 1: Bicep โ€” Premium namespace, Entra-only, with a queue

param namespaceName string
param location string = resourceGroup().location

resource ns 'Microsoft.ServiceBus/namespaces@2024-01-01' = {
  name: namespaceName
  location: location
  sku: { name: 'Premium', tier: 'Premium', capacity: 1 }   // 1 MU = ~1000 msg/s
  properties: {
    disableLocalAuth: true                                  // Entra only
    publicNetworkAccess: 'Disabled'
    minimumTlsVersion: '1.2'
  }
  identity: { type: 'SystemAssigned' }
}

resource ordersQueue 'Microsoft.ServiceBus/namespaces/queues@2024-01-01' = {
  parent: ns
  name: 'orders'
  properties: {
    maxDeliveryCount: 5                                     // โ†’ DLQ on 6th attempt
    lockDuration: 'PT1M'                                    // 1 min lock
    defaultMessageTimeToLive: 'P14D'
    deadLetteringOnMessageExpiration: true
    requiresSession: false
    enablePartitioning: true                                // Premium partitions
  }
}

Step 2: Send messages with .NET 9

using Azure.Identity;
using Azure.Messaging.ServiceBus;

var fqdn = "myns.servicebus.windows.net";
var client = new ServiceBusClient(fqdn, new DefaultAzureCredential());

await using var sender = client.CreateSender("orders");

var batch = await sender.CreateMessageBatchAsync();
foreach (var order in orders)
{
    var msg = new ServiceBusMessage(JsonSerializer.SerializeToUtf8Bytes(order))
    {
        ContentType = "application/json",
        MessageId = order.Id.ToString(),         // de-dup window if enabled
        SessionId = order.CustomerId,            // FIFO per customer
        ApplicationProperties = { ["type"] = order.Type, ["amount"] = order.Amount },
    };
    if (!batch.TryAddMessage(msg))
        throw new InvalidOperationException("Message too large for batch");
}
await sender.SendMessagesAsync(batch);

Step 3: Receive with peek-lock โ€” the only safe mode

var processor = client.CreateProcessor("orders", new ServiceBusProcessorOptions
{
    AutoCompleteMessages = false,                // YOU complete on success
    MaxConcurrentCalls = 16,
    PrefetchCount = 32,
    ReceiveMode = ServiceBusReceiveMode.PeekLock // default; never use ReceiveAndDelete in prod
});

processor.ProcessMessageAsync += async args =>
{
    try
    {
        var order = JsonSerializer.Deserialize<Order>(args.Message.Body);
        await orderService.ProcessAsync(order!);   // MUST be idempotent
        await args.CompleteMessageAsync(args.Message);
    }
    catch (PoisonMessageException ex)              // permanent failure
    {
        await args.DeadLetterMessageAsync(args.Message, "Poison",
            ex.Message.Substring(0, Math.Min(ex.Message.Length, 4096)));
    }
    // Any other unhandled exception โ†’ message is abandoned by the processor
    // and redelivered until MaxDeliveryCount โ†’ auto-DLQ.
};

processor.ProcessErrorAsync += args => { logger.LogError(args.Exception, "SB"); return Task.CompletedTask; };
await processor.StartProcessingAsync();

Step 4: Topic with subscription filters

resource topic 'Microsoft.ServiceBus/namespaces/topics@2024-01-01' = {
  parent: ns
  name: 'orders'
}

resource billingSub 'Microsoft.ServiceBus/namespaces/topics/subscriptions@2024-01-01' = {
  parent: topic
  name: 'billing'
  properties: { maxDeliveryCount: 5, deadLetteringOnFilterEvaluationExceptions: true }
}

resource billingRule 'Microsoft.ServiceBus/namespaces/topics/subscriptions/rules@2024-01-01' = {
  parent: billingSub
  name: 'paid-only'
  properties: {
    filterType: 'SqlFilter'
    sqlFilter: { sqlExpression: "type = 'paid'" }
  }
}

Step 5: Sessions โ€” strict FIFO per partition key

// Queue must have requiresSession = true
var sessionProcessor = client.CreateSessionProcessor("orders-fifo", new ServiceBusSessionProcessorOptions
{
    MaxConcurrentSessions = 8,                  // 8 customers in parallel
    MaxConcurrentCallsPerSession = 1,           // strict order within a session
});

sessionProcessor.ProcessMessageAsync += async args =>
{
    // args.SessionId is the customer id โ€” guaranteed in-order per session
    await ApplyAsync(args.Message);
    await args.CompleteMessageAsync(args.Message);
};

Step 6: DLQ replay โ€” operational must-have

// Read from the DLQ sub-queue
var dlqReceiver = client.CreateReceiver("orders",
    new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });

await foreach (var msg in dlqReceiver.ReceiveMessagesAsync())
{
    if (await CanReprocessAsync(msg))
    {
        // Resubmit to main queue
        await sender.SendMessageAsync(new ServiceBusMessage(msg) { MessageId = Guid.NewGuid().ToString() });
        await dlqReceiver.CompleteMessageAsync(msg);
    }
}

Pitfalls

1. ReceiveAndDelete in production. The broker hands you the message and immediately deletes it. Crash mid-processing and the message is gone. PeekLock with explicit CompleteMessageAsync is the only durable mode.

2. Non-idempotent consumers.Service Bus guarantees at-least-once. Network blips between "process succeeded" and "Complete acked" will redeliver. Build idempotency into the consumer (dedup table on MessageId, or upsert semantics).

3. MaxDeliveryCount = 10 with no DLQ alerting. Bad messages silently retry 10 times then sit forever in the DLQ. Set an alert on DLQ message count and a runbook to triage daily.

4. Confusing Service Bus with Event Hubs.Service Bus = messages, transactional, queue/topic, ordering optional, "each message matters". Event Hubs = events, high-throughput streaming, partition-ordered, "the firehose". Use Service Bus when individual messages trigger work; Event Hubs when you're ingesting telemetry at scale.

5. Lock duration too short. If your processing takes longer than lockDuration, the broker releases the lock and a second consumer picks up the message โ€” duplicate work. Either raise the lock or call RenewMessageLockAsync in a heartbeat.

6. Connection-string auth still in code. Set disableLocalAuth: true and use DefaultAzureCredential. The managed-identity story is fully supported across the Azure SDKs.

Practical Takeaways

  • Provision Premium for prod (predictable latency, VNet, partitions, 100 MB messages, geo-DR). Standard for dev.
  • Disable local auth, use Entra. The Service Bus Data Receiver / Sender / Owner built-in roles cover almost all cases.
  • Always peek-lock, always idempotent consumers, always set MaxDeliveryCount + DLQ alerting.
  • Use sessions (SessionId) when you need ordering within a partition key โ€” never try to roll your own ordering.
  • Topics + SQL filters keep fan-out simple. One topic, many subscriptions, one processing pipeline per concern.
  • Build a DLQ replay job into day-one ops, not as an afterthought.
  • Pick Service Bus for "workflow" semantics. Pick Event Hubs for raw telemetry. Pick Event Grid for reactive system events.