Azure Service Bus: Managed Messaging with Queues and Topics
A practical guide to Azure Service Bus — Microsoft's fully managed enterprise messaging service — covering queues vs. topics/subscriptions, message sessions, delivery guarantees, dead-lettering, .NET integration, and how it compares to RabbitMQ and Kafka.
Table of Contents
- Introduction
- Queues: Point-to-Point Messaging
- Topics and Subscriptions: Publish/Subscribe
- Message Sessions: Ordered, Stateful Processing
- Delivery Guarantees and the PeekLock Model
- Dead-Lettering
- Scheduled and Deferred Messages
- Duplicate Detection
- .NET Integration with Azure.Messaging.ServiceBus
- Transactions
- Auto-Forwarding and Topologies
- Pricing Tiers and Namespace Design
- Service Bus vs. RabbitMQ vs. Kafka vs. Event Grid
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
Azure Service Bus is Microsoft's fully managed enterprise messaging service — a cloud-native alternative to self-hosting RabbitMQ (covered in this series' companion guide) that provides queues, publish/subscribe topics, and a set of enterprise-messaging features (sessions, transactions, duplicate detection) without any broker infrastructure to provision, patch, or cluster yourself.
await using var client = new ServiceBusClient(connectionString, new DefaultAzureCredential());
await using var sender = client.CreateSender("order-processing");
var message = new ServiceBusMessage(JsonSerializer.SerializeToUtf8Bytes(order))
{
ContentType = "application/json",
MessageId = order.Id.ToString()
};
await sender.SendMessageAsync(message);
If you've read this series' RabbitMQ guide, much of Service Bus will feel conceptually familiar — the core ideas (queues, at-least-once delivery, dead-lettering) carry over directly. This guide focuses on where Service Bus differs, what it adds as a managed, enterprise-focused service, and how to use it well from .NET.
1. Queues: Point-to-Point Messaging
Basic queue send/receive
az servicebus queue create --resource-group my-rg --namespace-name my-servicebus --name order-processing
await using var sender = client.CreateSender("order-processing");
await sender.SendMessageAsync(new ServiceBusMessage(orderJson));
await using var receiver = client.CreateReceiver("order-processing");
ServiceBusReceivedMessage received = await receiver.ReceiveMessageAsync();
await receiver.CompleteMessageAsync(received);
A Service Bus queue is directly analogous to a RabbitMQ queue — a single logical destination where each message is delivered to and processed by exactly one consumer, giving the same competing-consumers scaling model covered in this series' RabbitMQ guide when multiple receiver instances share a queue.
Queues vs. RabbitMQ: no separate exchange concept
Unlike RabbitMQ's publisher-always-sends-to-an-exchange model (covered in the RabbitMQ guide), Service Bus queues are sent to directly by name — there's no separate routing layer for queues specifically; the equivalent of RabbitMQ's flexible exchange-based routing is what topics and subscriptions provide instead (Section 2).
2. Topics and Subscriptions: Publish/Subscribe
The core publish/subscribe model
az servicebus topic create --resource-group my-rg --namespace-name my-servicebus --name order-events
az servicebus topic subscription create --resource-group my-rg --namespace-name my-servicebus --topic-name order-events --name email-service
az servicebus topic subscription create --resource-group my-rg --namespace-name my-servicebus --topic-name order-events --name inventory-service
Topic: order-events
Subscription: email-service ← receives its own independent copy of every matching message
Subscription: inventory-service ← receives its own independent copy of every matching message
A topic accepts published messages, and any number of subscriptions attached to it each receive their own independent copy — this is Service Bus's equivalent of RabbitMQ's fanout exchange (Section 3 of the RabbitMQ guide), but with subscriptions as durable, independently-managed entities rather than requiring manually declared queues bound to an exchange.
Publishing to a topic
await using var sender = client.CreateSender("order-events");
await sender.SendMessageAsync(new ServiceBusMessage(orderCreatedEventJson));
Consuming from a subscription
await using var receiver = client.CreateReceiver("order-events", subscriptionName: "email-service");
var message = await receiver.ReceiveMessageAsync();
Each subscription behaves like its own independent queue — with its own message backlog, its own dead-letter queue (Section 5), and its own set of consumers — while all subscriptions on the same topic see the same published messages (subject to any filters, below), directly mirroring the multiple-independent-consumer-groups capability covered in this series' Kafka guide, just implemented via a different underlying mechanism.
Subscription filters: selective delivery, without a separate exchange type
await adminClient.CreateRuleAsync("order-events", "high-value-orders",
new CreateRuleOptions("HighValueFilter", new SqlRuleFilter("Total > 500")));
// Correlation filter — matching on specific message properties, generally faster than a SQL filter
await adminClient.CreateRuleAsync("order-events", "premium-orders",
new CreateRuleOptions("PremiumFilter", new CorrelationRuleFilter { Subject = "premium" }));
Rather than RabbitMQ's routing-key-and-exchange-type model (topic/direct/fanout/headers), Service Bus subscriptions apply rules — SQL-like filter expressions evaluated against message properties, or lighter-weight correlation filters — directly to each subscription, determining which published messages that specific subscription actually receives. A subscription with no explicit rule defaults to receiving every message (the fanout behavior); adding a filter narrows it to only matching messages, giving topic-exchange-like selective routing without needing a distinct exchange type to declare upfront.
3. Message Sessions: Ordered, Stateful Processing
The problem sessions solve
As covered in this series' Kafka guide, maintaining strict ordering for a group of related messages (all events for one order, one customer, one conversation) typically requires a partition-key-style mechanism — Service Bus's answer to this within its queue/topic model is sessions.
var message = new ServiceBusMessage(eventJson) { SessionId = orderId.ToString() };
await sender.SendMessageAsync(message);
await using var sessionReceiver = await client.AcceptNextSessionAsync("order-events-queue");
var messages = await sessionReceiver.ReceiveMessagesAsync(maxMessages: 10);
// all messages received here share the same SessionId, and are delivered in the order they were sent
Setting a SessionId on related messages guarantees they're delivered in order, to a single consumer at a time, for the duration of that session — conceptually similar to Kafka's per-key ordering within a partition, but implemented as an explicit, queue/topic-native feature rather than something achieved through partition key hashing.
Session state: carrying context across a related sequence of messages
await sessionReceiver.SetSessionStateAsync(BinaryData.FromString(JsonSerializer.Serialize(currentOrderState)));
var state = await sessionReceiver.GetSessionStateAsync();
Sessions can also carry small amounts of arbitrary state, associated with the session itself rather than any individual message — useful for a consumer that needs to accumulate context across a multi-message sequence (assembling a multi-part order, tracking a multi-step workflow) without needing external storage for that intermediate state.
When to use sessions
Sessions are the right tool specifically when a group of related messages must be processed strictly in order, by one consumer, one at a time — for messages with no such ordering requirement, plain (non-sessioned) queues/subscriptions scale better, since sessions inherently limit a given session's messages to sequential processing by a single consumer, trading some parallelism for the ordering guarantee.
4. Delivery Guarantees and the PeekLock Model
PeekLock: Service Bus's equivalent of manual acknowledgment
await using var receiver = client.CreateReceiver("order-processing",
new ServiceBusReceiverOptions { ReceiveMode = ServiceBusReceiveMode.PeekLock }); // the default
var message = await receiver.ReceiveMessageAsync();
try
{
await ProcessOrderAsync(message);
await receiver.CompleteMessageAsync(message); // acknowledges — message removed from the queue
}
catch
{
await receiver.AbandonMessageAsync(message); // releases the lock, message becomes available again
}
PeekLock mode (the default and recommended mode) locks a message for a configurable duration once delivered to a receiver — the message remains in the queue, invisible to other receivers, until the receiver explicitly completes it (removing it permanently), abandons it (releasing the lock for redelivery), or the lock simply expires (also triggering redelivery). This is functionally equivalent to RabbitMQ's manual acknowledgment model covered in this series' companion guide — the same at-least-once delivery semantics and the same need for idempotent message handling apply identically here.
ReceiveAndDelete: the equivalent of RabbitMQ's autoAck: true
new ServiceBusReceiverOptions { ReceiveMode = ServiceBusReceiveMode.ReceiveAndDelete }
Messages are removed from the queue the instant they're delivered, before any processing occurs — faster, but risks silent message loss if the receiver crashes mid-processing, exactly the same trade-off covered for RabbitMQ's auto-ack mode. Appropriate only for genuinely low-stakes, loss-tolerant messages.
Lock renewal for long-running processing
await using var receiver = client.CreateReceiver("order-processing");
var message = await receiver.ReceiveMessageAsync();
// For processing that might exceed the default lock duration:
await using var lockRenewer = new AutoLockRenewer(); // or manually: await receiver.RenewMessageLockAsync(message);
If processing a message takes longer than its lock duration (configurable per queue/subscription, commonly 30 seconds to a few minutes by default), the lock expires and the message becomes available for redelivery to another receiver — while the original receiver is still working on it, risking duplicate concurrent processing. For genuinely long-running processing, explicitly renewing the lock periodically (or using the SDK's automatic lock renewal helper) prevents this, extending the lock for as long as processing legitimately continues.
5. Dead-Lettering
Automatic dead-lettering, built directly into the queue/subscription
await using var receiver = client.CreateReceiver("order-processing");
// ... after MaxDeliveryCount is exceeded automatically, or explicitly:
await receiver.DeadLetterMessageAsync(message, deadLetterReason: "ValidationFailed");
az servicebus queue update --resource-group my-rg --namespace-name my-servicebus --name order-processing --max-delivery-count 5
Unlike RabbitMQ, where a dead letter exchange requires explicit configuration (a separate exchange, a binding, and a queue argument, as covered in this series' RabbitMQ guide), Service Bus queues and subscriptions have a dead-letter sub-queue built in automatically — every queue and subscription implicitly has one, and MaxDeliveryCount (a simple per-queue/subscription setting) automatically moves a message there once it's been delivered and abandoned/expired that many times, with no additional topology to declare.
Consuming from the dead-letter queue
await using var dlqReceiver = client.CreateReceiver("order-processing",
new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });
var deadLetteredMessage = await dlqReceiver.ReceiveMessageAsync();
Console.WriteLine($"Dead-lettered: {deadLetteredMessage.DeadLetterReason} - {deadLetteredMessage.DeadLetterErrorDescription}");
The dead-letter sub-queue is addressed via the same queue/subscription name with a SubQueue.DeadLetter designation — genuinely simpler to set up than RabbitMQ's explicit DLX configuration, at the cost of somewhat less routing flexibility (RabbitMQ's DLX can route to an arbitrary exchange with its own routing logic; Service Bus's dead-letter queue is a fixed, built-in destination per queue/subscription).
Explicit dead-lettering with a reason
await receiver.DeadLetterMessageAsync(message,
deadLetterReason: "InvalidOrderData",
deadLetterErrorDescription: "Missing required CustomerId field");
Beyond automatic dead-lettering after exceeding MaxDeliveryCount, application code can explicitly dead-letter a message it recognizes as unprocessable (a validation failure, a genuinely malformed payload) immediately, with a descriptive reason — avoiding wasted redelivery attempts for a message that's already known to be permanently unprocessable, and giving whoever investigates the dead-letter queue meaningful context rather than just a generic delivery-count-exceeded message.
6. Scheduled and Deferred Messages
Scheduling a message for future delivery
var message = new ServiceBusMessage(reminderJson);
await sender.ScheduleMessageAsync(message, DateTimeOffset.UtcNow.AddHours(24));
Service Bus supports natively scheduling a message to become available only at a specified future time — directly useful for reminder notifications, delayed retry patterns (achieving with a single built-in feature what this series' RabbitMQ guide implements via a TTL-plus-dead-letter-exchange workaround), or any workflow needing a deliberate delay before a message should actually be processed.
Deferring a message for later, explicit retrieval
await receiver.DeferMessageAsync(message); // sets it aside, NOT visible to normal receive calls
// later, once ready to handle it, using the message's sequence number:
var deferredMessage = await receiver.ReceiveDeferredMessageAsync(message.SequenceNumber);
Deferral is distinct from scheduling — a deferred message is explicitly set aside by the receiver (not automatically redelivered or made visible via normal receive calls) and can only be retrieved later by its specific sequence number, useful for out-of-order processing scenarios (e.g., "I've received message 3 of a 5-part sequence, but need to wait for messages 1 and 2 first") where a consumer needs to hold onto a message until some other condition is satisfied.
7. Duplicate Detection
Broker-side deduplication, based on MessageId
await using var sender = client.CreateSender("order-processing");
var message = new ServiceBusMessage(orderJson) { MessageId = order.Id.ToString() };
await sender.SendMessageAsync(message);
az servicebus queue create --resource-group my-rg --namespace-name my-servicebus --name order-processing \
--enable-duplicate-detection true --duplicate-detection-history-time-window "00:10:00"
When enabled on a queue/topic, Service Bus tracks MessageId values within a configurable time window and automatically discards a message with a MessageId it's already seen within that window — this directly addresses the producer-side duplicate risk covered for Kafka's idempotent producer feature in this series' Kafka guide, but implemented broker-side and keyed on an application-supplied ID rather than Kafka's lower-level producer-retry deduplication. This is a genuinely convenient built-in mitigation for the common "my publisher retried after a network blip and sent the same message twice" scenario — though it's scoped to a time window, not indefinite, and doesn't replace the need for idempotent consumer-side processing (Section 4) as the more complete, defense-in-depth solution to at-least-once delivery's duplicate risk generally.
8. .NET Integration with Azure.Messaging.ServiceBus
Setup and dependency injection
builder.Services.AddSingleton(_ =>
new ServiceBusClient(builder.Configuration["ServiceBus:Namespace"], new DefaultAzureCredential()));
builder.Services.AddSingleton(provider =>
provider.GetRequiredService<ServiceBusClient>().CreateSender("order-processing"));
ServiceBusClient (like ConnectionMultiplexer for Redis, covered in this series' Redis guide, and CosmosClient for Cosmos DB) is designed to be created once and shared for the application's lifetime — registered as a singleton, it manages connection pooling internally.
Authentication: Managed Identity as the recommended default
new ServiceBusClient(fullyQualifiedNamespace, new DefaultAzureCredential())
As covered in this series' Secret Management guide, DefaultAzureCredential combined with Managed Identity is the recommended authentication approach — eliminating the need to store a Service Bus connection string (which embeds a shared access key, itself a genuine secret) at all, when running on Azure compute that supports Managed Identity.
Processing messages with ServiceBusProcessor
public class OrderProcessingWorker : BackgroundService
{
private readonly ServiceBusProcessor _processor;
public OrderProcessingWorker(ServiceBusClient client)
{
_processor = client.CreateProcessor("order-processing", new ServiceBusProcessorOptions
{
MaxConcurrentCalls = 10,
AutoCompleteMessages = false
});
_processor.ProcessMessageAsync += HandleMessageAsync;
_processor.ProcessErrorAsync += HandleErrorAsync;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _processor.StartProcessingAsync(stoppingToken);
await Task.Delay(Timeout.Infinite, stoppingToken);
}
private async Task HandleMessageAsync(ProcessMessageEventArgs args)
{
var order = JsonSerializer.Deserialize<Order>(args.Message.Body);
await ProcessOrderAsync(order);
await args.CompleteMessageAsync(args.Message);
}
private Task HandleErrorAsync(ProcessErrorEventArgs args)
{
_logger.LogError(args.Exception, "Service Bus processing error");
return Task.CompletedTask;
}
}
ServiceBusProcessor is a higher-level abstraction over manual receive loops — it manages a pool of concurrent message-processing calls (MaxConcurrentCalls), automatic lock renewal for long-running handlers, and structured error handling via events, letting a BackgroundService (following the same hosting pattern covered throughout this series) focus purely on the actual message-handling logic rather than the receive-loop mechanics.
9. Transactions
Atomic operations across multiple messaging actions
await using var transaction = new ServiceBusTransaction(); // conceptual — actual API uses ServiceBusClient's transaction support via TransactionScope
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
await receiver.CompleteMessageAsync(incomingMessage);
await sender.SendMessageAsync(new ServiceBusMessage(outgoingEventJson));
scope.Complete();
Service Bus supports participating in .NET's TransactionScope, letting a "complete this incoming message" and "send this outgoing message" pair succeed or fail together atomically — directly analogous to the Kafka transactions covered in this series' Kafka guide for "consume, process, produce" pipelines, giving the same all-or-nothing guarantee for a specific, common messaging pattern (receiving a message, taking some action, and publishing a resulting event) without needing to build that coordination manually.
Scope and limitations
As with Kafka's transactional guarantees, Service Bus transactions cover operations within Service Bus itself (and, via TransactionScope's broader .NET transaction coordination, potentially a local SQL Server operation in the same scope) — they don't extend atomicity to arbitrary external systems (an HTTP call to a third-party API, for instance), so the same idempotency discipline covered throughout this series' messaging guides remains necessary for anything touching systems outside the transaction's actual scope.
10. Auto-Forwarding and Topologies
Chaining queues and subscriptions without application code
az servicebus queue update --resource-group my-rg --namespace-name my-servicebus \
--name order-processing --forward-to order-processing-archive
Auto-forwarding lets a queue or subscription automatically forward every message it receives to another queue or topic — entirely broker-side, with no consumer application needed in between — useful for building multi-stage processing topologies (a subscription filtering high-value orders that auto-forwards into a separate, more heavily-monitored processing queue) or simple archival patterns, without writing and deploying a dedicated forwarding service.
Building fan-out-then-filter topologies
Topic: order-events
Subscription: "high-value" (filter: Total > 1000) → auto-forwards to → Queue: fraud-review
Subscription: "standard" (filter: Total <= 1000) → consumed directly by the normal processing service
Combining topic subscriptions' filtering (Section 2) with auto-forwarding lets fairly sophisticated routing topologies be expressed entirely as Service Bus configuration — declared once via the Azure CLI/Bicep/Terraform (connecting to this series' Terraform/Bicep guide), rather than requiring custom application code to inspect and re-route messages.
11. Pricing Tiers and Namespace Design
Basic, Standard, and Premium tiers
| Tier | Key differences |
|---|---|
| Basic | Queues only, no topics/subscriptions, no sessions, pay-per-operation |
| Standard | Full feature set (topics, sessions, transactions), pay-per-operation with a base charge |
| Premium | Dedicated, predictable-performance resources (not shared multi-tenant capacity), larger message sizes, virtual network integration, higher throughput ceilings |
The tier decision is mostly about which features are actually needed (topics require at least Standard) and, for Premium specifically, whether workload volume and latency-predictability requirements justify dedicated capacity over the shared, pay-per-operation model of Basic/Standard.
Namespace organization
Namespace: my-app-prod
Queue: order-processing
Queue: email-notifications
Topic: order-events
Subscription: inventory-service
Subscription: analytics-service
A Service Bus namespace is the top-level container (analogous to a RabbitMQ virtual host) — most applications use a small number of namespaces (often one per environment: dev/staging/production), with queues and topics organized underneath, rather than one namespace per individual queue, since namespace-level settings (network access rules, Managed Identity role assignments) are more naturally managed at that broader scope.
12. Service Bus vs. RabbitMQ vs. Kafka vs. Event Grid
| Azure Service Bus | RabbitMQ | Kafka | Azure Event Grid | |
|---|---|---|---|---|
| Model | Managed enterprise messaging (queues + topics) | Self-hosted (or managed), exchange-based routing | Distributed, retained, replayable log | Managed, event-driven pub/sub for reactive architectures |
| Operational burden | None — fully managed | Self-hosted clustering, or a managed offering | Highest — partition/broker/replication management | None — fully managed |
| Ordering guarantees | Via sessions | Per-queue FIFO (no partition concept) | Per-partition, via partition key | No ordering guarantee |
| Message retention/replay | Time-limited (dead-letter and deferral aside), not a replay-oriented model | Consumed-and-removed | Long-term retained, fully replayable | Not retained — at-most-once-ish delivery for reactive triggers |
| Enterprise features | Sessions, transactions, duplicate detection, auto-forwarding — built in | Requires more manual assembly (DLX, TTL tricks) for equivalent behavior | Requires external tooling (Schema Registry, Streams) for equivalent governance | Minimal — designed for lightweight event routing, not durable processing |
| Best fit | Azure-native enterprise applications wanting managed, feature-rich messaging | Flexible routing, self-hosted or cross-cloud portability | High-volume event streaming, replay, multiple independent consumers | Lightweight reactive triggers (a blob uploaded, a resource changed) |
Practical guidance
- Building on Azure, want enterprise messaging features (sessions, transactions, dead-lettering) without operating broker infrastructure yourself? → Service Bus is the natural, purpose-built choice.
- Need Kafka's replay/high-volume streaming capabilities specifically? → Service Bus isn't the right fit; use Kafka directly or Azure Event Hubs (Azure's Kafka-like managed streaming service, distinct from Service Bus).
- Need RabbitMQ's exchange-type routing flexibility, or want to avoid cloud vendor lock-in? → RabbitMQ (self-hosted or via a managed RabbitMQ offering) remains the more portable choice.
- Need lightweight, fire-and-forget event routing for reactive automation (not durable, guaranteed-delivery business messaging)? → Azure Event Grid is a different, lighter-weight tool, worth distinguishing from Service Bus specifically for that use case.
13. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
Using ReceiveAndDelete mode for anything important |
Silent message loss if the receiver crashes mid-processing | Use PeekLock (the default) and explicitly complete only after successful processing |
| Not renewing locks for long-running message processing | The lock expires mid-processing, risking duplicate concurrent handling | Use AutoLockRenewer or explicit periodic RenewMessageLockAsync calls |
| Forgetting sessions require a session-aware receiver | A non-session receiver can't receive from a session-enabled queue at all | Use AcceptNextSessionAsync/session-specific receivers when RequiresSession is enabled |
| Assuming duplicate detection alone guarantees exactly-once | It's a time-windowed, MessageId-based mitigation, not a complete guarantee |
Still design consumers to be idempotent |
| Storing a Service Bus connection string (with an embedded shared access key) in application config | A long-lived credential sitting in configuration, per this series' Secret Management guide | Use Managed Identity with DefaultAzureCredential instead |
| Never checking the dead-letter queue | Failed messages accumulate silently, with no visibility into recurring processing failures | Monitor dead-letter queue depth; alert on sustained growth |
| Choosing Basic tier, then needing topics later | Basic tier doesn't support topics/subscriptions at all — requires a tier migration | Default to Standard tier unless queue-only, cost-sensitive use is clearly sufficient |
Quick Reference Table
| Concept | Purpose |
|---|---|
| Queue | Point-to-point, competing-consumers messaging |
| Topic + Subscription | Publish/subscribe, each subscription an independent copy |
| Subscription filter/rule | Selective delivery to a specific subscription, SQL-like or correlation-based |
Session (SessionId) |
Ordered, single-consumer-at-a-time processing for related messages |
| PeekLock | Manual acknowledgment mode; the recommended default |
MaxDeliveryCount |
Automatic dead-lettering threshold, built in per queue/subscription |
| Scheduled message | Native delayed delivery to a future time |
| Deferred message | Explicitly set aside, retrieved later by sequence number |
| Duplicate detection | Broker-side, MessageId-based, time-windowed deduplication |
| Auto-forwarding | Broker-side chaining of queues/topics without application code |
ServiceBusProcessor |
High-level .NET abstraction managing concurrent processing and lock renewal |
Conclusion
Azure Service Bus takes the core messaging concepts covered in this series' RabbitMQ guide and packages them as a fully managed service with a distinctly enterprise-messaging feature set built in from the start — sessions for ordered processing, transactions spanning multiple messaging operations, duplicate detection, and dead-lettering that requires no manual topology configuration the way RabbitMQ's does. For teams building on Azure who want these capabilities without operating broker infrastructure themselves, it's a strong, purpose-built default.
The right choice among Service Bus, RabbitMQ, and Kafka — echoing this series' Kafka guide's conclusion — comes down to matching the tool to the actual problem shape: Service Bus for managed, feature-rich enterprise messaging within the Azure ecosystem; RabbitMQ for flexible routing with self-hosted or cross-cloud portability; Kafka for high-volume, replayable event streaming with many independent downstream consumers. All three share the same underlying disciplines this series has emphasized throughout — at-least-once delivery by default, idempotent consumers, deliberate dead-letter handling, and bounded retry policies — the specific mechanism for each just differs by platform.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the session-based ordering fix that resolved a race condition RabbitMQ or Kafka would have needed a different trick to solve.
Top comments (1)
the delivery guarantee section covers the risks that matter in production. i would add a small failure matrix for lock expiry, handler retry, duplicate message ids, and dead letter recovery, with the required consumer behavior for each case. a trace id and message id in logs can connect the original delivery to later retries. this makes it easier to prove that a handler is safe under at least once delivery.