dotnet-messaging-patterns
Durable messaging patterns for .NET event-driven architectures. Covers publish/subscribe, competing consumers,
dead-letter queues, saga/process manager orchestration, and delivery guarantee strategies using Azure Service Bus,
RabbitMQ, and MassTransit.
Scope
- Publish/subscribe and competing consumer patterns
- Dead-letter queues and poison message handling
- Saga/process manager orchestration
- Delivery guarantee strategies (at-least-once, exactly-once)
- Azure Service Bus, RabbitMQ, and MassTransit integration
Out of scope
- Background service lifecycle and IHostedService registration -- see [skill:dotnet-background-services]
- Resilience pipelines and retry policies -- see [skill:dotnet-resilience]
- JSON/binary serialization configuration -- see [skill:dotnet-serialization]
- In-process producer/consumer queues with Channel -- see [skill:dotnet-channels]
Cross-references: [skill:dotnet-background-services] for hosting message consumers, [skill:dotnet-resilience] for fault
tolerance around message handlers, [skill:dotnet-serialization] for message envelope serialization,
[skill:dotnet-channels] for in-process queuing patterns.
Messaging Fundamentals
Message Types
| Type |
Purpose |
Example |
| Command |
Request an action (one recipient) |
PlaceOrder, ShipPackage |
| Event |
Notify something happened (many recipients) |
OrderPlaced, PaymentReceived |
| Document |
Transfer data between systems |
CustomerProfile, ProductCatalog |
Commands are sent to a specific queue; events are published to a topic/exchange and delivered to all subscribers. This
distinction drives the choice between point-to-point and pub/sub topologies.
Delivery Guarantees
| Guarantee |
Behavior |
Implementation |
| At-most-once |
Fire and forget; message may be lost |
No ack, no retry |
| At-least-once |
Message retried until acknowledged; duplicates possible |
Ack after processing + retry on failure |
| Exactly-once |
Each message processed exactly once |
At-least-once + idempotent consumer |
At-least-once with idempotent consumers is the standard approach for durable messaging. True exactly-once requires
distributed transactions (which most brokers do not support) or consumer-side deduplication.
Publish/Subscribe
Azure Service Bus Topics
// Publisher -- send event to a topic
await using var client = new ServiceBusClient(connectionString);
await using var sender = client.CreateSender("order-events");
var message = new ServiceBusMessage(
JsonSerializer.SerializeToUtf8Bytes(new OrderPlaced(orderId, total)))
{
Subject = nameof(OrderPlaced),
ContentType = "application/json",
MessageId = Guid.NewGuid().ToString()
};
await sender.SendMessageAsync(message, cancellationToken);
```text
```csharp
// Subscriber -- process events from a subscription
await using var processor = client.CreateProcessor(
topicName: "order-events",
subscriptionName: "billing-service",
new ServiceBusProcessorOptions
{
MaxConcurrentCalls = 10,
AutoCompleteMessages = false
});
processor.ProcessMessageAsync += async args =>
{
var body = args.Message.Body.ToObjectFromJson<OrderPlaced>();
await HandleOrderPlacedAsync(body);
await args.CompleteMessageAsync(args.Message);
};
processor.ProcessErrorAsync += args =>
{
logger.LogError(args.Exception, "Error processing message");
return Task.CompletedTask;
};
await processor.StartProcessingAsync(cancellationToken);
```text
**Key packages:**
```xml
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.*" />
```xml
### RabbitMQ Fanout Exchange
```csharp
// Publisher -- declare exchange and publish
var factory = new ConnectionFactory { HostName = "localhost" };
await using var connection = await factory.CreateConnectionAsync();
await using var channel = await connection.CreateChannelAsync();
await channel.ExchangeDeclareAsync(
exchange: "order-events",
type: ExchangeType.Fanout,
durable: true);
var body = JsonSerializer.SerializeToUtf8Bytes(
new OrderPlaced(orderId, total));
await channel.BasicPublishAsync(
exchange: "order-events",
routingKey: string.Empty,
body: body);
```text
**Key packages:**
```xml
<PackageReference Include="RabbitMQ.Client" Version="7.*" />
```xml
### MassTransit Publish
MassTransit abstracts the broker, providing a unified API for Azure Service Bus, RabbitMQ, Amazon SQS, and in-memory
transport.
```csharp
// Registration
builder.Services.AddMassTransit(x =>
{
x.AddConsumer<OrderPlacedConsumer>();
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("localhost", "/", h =>
{
h.Username("guest");
h.Password("guest");
});
cfg.ConfigureEndpoints(context);
});
});
// Publisher
public sealed class OrderService(IPublishEndpoint publishEndpoint)
{
public async Task PlaceOrderAsync(
Guid orderId, decimal total, CancellationToken ct)
{
// Process order...
await publishEndpoint.Publish(
new OrderPlaced(orderId, total), ct);
}
}
// Consumer
public sealed class OrderPlacedConsumer(
ILogger<OrderPlacedConsumer> logger)
: IConsumer<OrderPlaced>
{
public async Task Consume(ConsumeContext<OrderPlaced> context)
{
logger.LogInformation(
"Processing order {OrderId}", context.Message.OrderId);
await ProcessAsync(context.Message);
}
}
// Message contract (use records in a shared contracts assembly)
public record OrderPlaced(Guid OrderId, decimal Total);
```text
**Key packages:**
```xml
<PackageReference Include="MassTransit" Version="8.*" />
<!-- Pick ONE transport: -->
<PackageReference Include="MassTransit.RabbitMQ" Version="8.*" />
<!-- OR -->
<PackageReference Include="MassTransit.Azure.ServiceBus.Core" Version="8.*" />
```text
---
## Competing Consumers
Multiple consumer instances process messages from the same queue in parallel. The broker delivers each message to
exactly one consumer, distributing load across instances.
### Pattern
```text
Queue: order-processing
├── Consumer Instance A (picks message 1)
├── Consumer Instance B (picks message 2)
└── Consumer Instance C (picks message 3)
```text
### Azure Service Bus -- Scaling Consumers
```csharp
// Multiple instances reading from the same queue automatically compete.
// MaxConcurrentCalls controls per-instance parallelism.
var processor = client.CreateProcessor("order-processing",
new ServiceBusProcessorOptions
{
MaxConcurrentCalls = 20,
PrefetchCount = 50,
AutoCompleteMessages = false
});
```text
### MassTransit -- Concurrency Limits
```csharp
x.AddConsumer<OrderProcessor>(cfg =>
{
cfg.UseConcurrentMessageLimit(10);
});
```text
### Ordering Considerations
Competing consumers sacrifice strict ordering for throughput. When order matters:
- **Azure Service Bus**: Use sessions (`RequiresSession = true`) to guarantee FIFO within a session ID (e.g., per
customer)
- **RabbitMQ**: Use a single consumer per queue, or consistent-hash exchange to partition by key
- **MassTransit**: Configure `UseMessagePartitioner` for key-based ordering
---
## Dead-Letter Queues
Dead-letter queues (DLQs) capture messages that cannot be processed after exhausting retries. They prevent poison
messages from blocking the main queue.
### Why Messages Are Dead-Lettered
| Reason | Trigger |
| ------------------------------ | -------------------------------------------- |
| Max delivery attempts exceeded | Message failed processing N times |
| TTL expired | Message sat in queue past its time-to-live |
| Consumer rejection | Consumer explicitly dead-letters the message |
| Queue length exceeded | Queue overflow policy routes to DLQ |
### Azure Service Bus DLQ
```csharp
// Dead-letter a message with reason
await args.DeadLetterMessageAsync(
args.Message,
deadLetterReason: "ValidationFailed",
deadLetterErrorDescription: "Missing required field: CustomerId");
// Read from the dead-letter sub-queue
await using var dlqReceiver = client.CreateReceiver(
"order-processing",
new ServiceBusReceiverOptions
{
SubQueue = SubQueue.DeadLetter
});
while (true)
{
var message = await dlqReceiver.ReceiveMessageAsync(
TimeSpan.FromSeconds(5), cancellationToken);
if (message is null) break;
logger.LogWarning(
"DLQ message: {Reason} - {Description}",
message.DeadLetterReason,
message.DeadLetterErrorDescription);
// Inspect, fix, and re-submit or discard
await dlqReceiver.CompleteMessageAsync(message);
}
```text
### MassTransit Error/Fault Queues
MassTransit automatically creates `_error` and `_skipped` queues. Failed messages after retry exhaustion move to the
error queue with fault metadata.
```csharp
// Configure retry before dead-lettering
x.AddConsumer<OrderProcessor>(cfg =>
{
cfg.UseMessageRetry(r => r.Intervals(
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(15)));
});
```text
### DLQ Monitoring
Always monitor DLQ depth with alerts. Unmonitored DLQs accumulate silently until data is lost or stale.
---
## Saga / Process Manager
Sagas coordinate multi-step business processes across services. Each step publishes events that trigger the next step,
with compensation logic for failures.
### Choreography vs Orchestration
| Style | How it works | Use when |
| ----------------- | -------------------------------------------------------------- | ------------------------------------------------------- |
| **Choreography** | Services react to events independently; no central coordinator | Simple flows, few steps, loosely coupled |
| **Orchestration** | A saga/process manager directs each step | Complex flows, compensation needed, visibility required |
### MassTransit State Machine Saga
```csharp
// Saga state
public class OrderState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; }
public string CurrentState { get; set; } = default!;
public Guid OrderId { get; set; }
public decimal Total { get; set; }
public DateTime? PaymentReceivedAt { get; set; }
}
// State machine definition
public sealed class OrderStateMachine : MassTransitStateMachine<OrderState>
{
public State Submitted { get; private set; } = default!;
public State PaymentPending { get; private set; } = default!;
public State Completed { get; private set; } = default!;
public State Faulted { get; private set; } = default!;
public Event<OrderSubmitted> OrderSubmitted { get; private set; } = default!;
public Event<PaymentReceived> PaymentReceived { get; private set; } = default!;
public Event<PaymentFailed> PaymentFailed { get; private set; } = default!;
public OrderStateMachine()
{
## Detailed Examples
See [references/detailed-examples.md](references/detailed-examples.md) for complete code samples and advanced patterns.
1---2name: dotnet-messaging-patterns3description: Builds event-driven systems. Pub/sub, competing consumers, DLQ, sagas, delivery guarantees.4license: MIT5targets: ['*']6tags: [architecture, dotnet, skill]7version: '0.0.1'8author: 'dotnet-agent-harness'9invocable: true10claudecode:11 allowed-tools: ['Read', 'Grep', 'Glob', 'Bash', 'Write', 'Edit']12codexcli:13 short-description: '.NET skill guidance for architecture tasks'14opencode:15 allowed-tools: ['Read', 'Grep', 'Glob', 'Bash', 'Write', 'Edit']16copilot: {}17geminicli: {}18antigravity: {}19category: fundamentals20subcategory: coding-standards21---2223# dotnet-messaging-patterns2425Durable messaging patterns for .NET event-driven architectures. Covers publish/subscribe, competing consumers,26dead-letter queues, saga/process manager orchestration, and delivery guarantee strategies using Azure Service Bus,27RabbitMQ, and MassTransit.2829## Scope3031- Publish/subscribe and competing consumer patterns32- Dead-letter queues and poison message handling33- Saga/process manager orchestration34- Delivery guarantee strategies (at-least-once, exactly-once)35- Azure Service Bus, RabbitMQ, and MassTransit integration3637## Out of scope3839- Background service lifecycle and IHostedService registration -- see [skill:dotnet-background-services]40- Resilience pipelines and retry policies -- see [skill:dotnet-resilience]41- JSON/binary serialization configuration -- see [skill:dotnet-serialization]42- In-process producer/consumer queues with Channel<T> -- see [skill:dotnet-channels]4344Cross-references: [skill:dotnet-background-services] for hosting message consumers, [skill:dotnet-resilience] for fault45tolerance around message handlers, [skill:dotnet-serialization] for message envelope serialization,46[skill:dotnet-channels] for in-process queuing patterns.4748---4950## Messaging Fundamentals5152### Message Types5354| Type | Purpose | Example |55| ------------ | ------------------------------------------- | ----------------------------------- |56| **Command** | Request an action (one recipient) | `PlaceOrder`, `ShipPackage` |57| **Event** | Notify something happened (many recipients) | `OrderPlaced`, `PaymentReceived` |58| **Document** | Transfer data between systems | `CustomerProfile`, `ProductCatalog` |5960Commands are sent to a specific queue; events are published to a topic/exchange and delivered to all subscribers. This61distinction drives the choice between point-to-point and pub/sub topologies.6263### Delivery Guarantees6465| Guarantee | Behavior | Implementation |66| ----------------- | ------------------------------------------------------- | --------------------------------------- |67| **At-most-once** | Fire and forget; message may be lost | No ack, no retry |68| **At-least-once** | Message retried until acknowledged; duplicates possible | Ack after processing + retry on failure |69| **Exactly-once** | Each message processed exactly once | At-least-once + idempotent consumer |7071**At-least-once with idempotent consumers** is the standard approach for durable messaging. True exactly-once requires72distributed transactions (which most brokers do not support) or consumer-side deduplication.7374---7576## Publish/Subscribe7778### Azure Service Bus Topics7980````csharp8182// Publisher -- send event to a topic83await using var client = new ServiceBusClient(connectionString);84await using var sender = client.CreateSender("order-events");8586var message = new ServiceBusMessage(87 JsonSerializer.SerializeToUtf8Bytes(new OrderPlaced(orderId, total)))88{89 Subject = nameof(OrderPlaced),90 ContentType = "application/json",91 MessageId = Guid.NewGuid().ToString()92};9394await sender.SendMessageAsync(message, cancellationToken);9596```text9798```csharp99100// Subscriber -- process events from a subscription101await using var processor = client.CreateProcessor(102 topicName: "order-events",103 subscriptionName: "billing-service",104 new ServiceBusProcessorOptions105 {106 MaxConcurrentCalls = 10,107 AutoCompleteMessages = false108 });109110processor.ProcessMessageAsync += async args =>111{112 var body = args.Message.Body.ToObjectFromJson<OrderPlaced>();113 await HandleOrderPlacedAsync(body);114 await args.CompleteMessageAsync(args.Message);115};116117processor.ProcessErrorAsync += args =>118{119 logger.LogError(args.Exception, "Error processing message");120 return Task.CompletedTask;121};122123await processor.StartProcessingAsync(cancellationToken);124125```text126127**Key packages:**128129```xml130131<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.*" />132133```xml134135### RabbitMQ Fanout Exchange136137```csharp138139// Publisher -- declare exchange and publish140var factory = new ConnectionFactory { HostName = "localhost" };141await using var connection = await factory.CreateConnectionAsync();142await using var channel = await connection.CreateChannelAsync();143144await channel.ExchangeDeclareAsync(145 exchange: "order-events",146 type: ExchangeType.Fanout,147 durable: true);148149var body = JsonSerializer.SerializeToUtf8Bytes(150 new OrderPlaced(orderId, total));151152await channel.BasicPublishAsync(153 exchange: "order-events",154 routingKey: string.Empty,155 body: body);156157```text158159**Key packages:**160161```xml162163<PackageReference Include="RabbitMQ.Client" Version="7.*" />164165```xml166167### MassTransit Publish168169MassTransit abstracts the broker, providing a unified API for Azure Service Bus, RabbitMQ, Amazon SQS, and in-memory170transport.171172```csharp173174// Registration175builder.Services.AddMassTransit(x =>176{177 x.AddConsumer<OrderPlacedConsumer>();178179 x.UsingRabbitMq((context, cfg) =>180 {181 cfg.Host("localhost", "/", h =>182 {183 h.Username("guest");184 h.Password("guest");185 });186 cfg.ConfigureEndpoints(context);187 });188});189190// Publisher191public sealed class OrderService(IPublishEndpoint publishEndpoint)192{193 public async Task PlaceOrderAsync(194 Guid orderId, decimal total, CancellationToken ct)195 {196 // Process order...197 await publishEndpoint.Publish(198 new OrderPlaced(orderId, total), ct);199 }200}201202// Consumer203public sealed class OrderPlacedConsumer(204 ILogger<OrderPlacedConsumer> logger)205 : IConsumer<OrderPlaced>206{207 public async Task Consume(ConsumeContext<OrderPlaced> context)208 {209 logger.LogInformation(210 "Processing order {OrderId}", context.Message.OrderId);211 await ProcessAsync(context.Message);212 }213}214215// Message contract (use records in a shared contracts assembly)216public record OrderPlaced(Guid OrderId, decimal Total);217218```text219220**Key packages:**221222```xml223224<PackageReference Include="MassTransit" Version="8.*" />225<!-- Pick ONE transport: -->226<PackageReference Include="MassTransit.RabbitMQ" Version="8.*" />227<!-- OR -->228<PackageReference Include="MassTransit.Azure.ServiceBus.Core" Version="8.*" />229230```text231232---233234## Competing Consumers235236Multiple consumer instances process messages from the same queue in parallel. The broker delivers each message to237exactly one consumer, distributing load across instances.238239### Pattern240241```text242243Queue: order-processing244 ├── Consumer Instance A (picks message 1)245 ├── Consumer Instance B (picks message 2)246 └── Consumer Instance C (picks message 3)247248```text249250### Azure Service Bus -- Scaling Consumers251252```csharp253254// Multiple instances reading from the same queue automatically compete.255// MaxConcurrentCalls controls per-instance parallelism.256var processor = client.CreateProcessor("order-processing",257 new ServiceBusProcessorOptions258 {259 MaxConcurrentCalls = 20,260 PrefetchCount = 50,261 AutoCompleteMessages = false262 });263264```text265266### MassTransit -- Concurrency Limits267268```csharp269270x.AddConsumer<OrderProcessor>(cfg =>271{272 cfg.UseConcurrentMessageLimit(10);273});274275```text276277### Ordering Considerations278279Competing consumers sacrifice strict ordering for throughput. When order matters:280281- **Azure Service Bus**: Use sessions (`RequiresSession = true`) to guarantee FIFO within a session ID (e.g., per282 customer)283- **RabbitMQ**: Use a single consumer per queue, or consistent-hash exchange to partition by key284- **MassTransit**: Configure `UseMessagePartitioner` for key-based ordering285286---287288## Dead-Letter Queues289290Dead-letter queues (DLQs) capture messages that cannot be processed after exhausting retries. They prevent poison291messages from blocking the main queue.292293### Why Messages Are Dead-Lettered294295| Reason | Trigger |296| ------------------------------ | -------------------------------------------- |297| Max delivery attempts exceeded | Message failed processing N times |298| TTL expired | Message sat in queue past its time-to-live |299| Consumer rejection | Consumer explicitly dead-letters the message |300| Queue length exceeded | Queue overflow policy routes to DLQ |301302### Azure Service Bus DLQ303304```csharp305306// Dead-letter a message with reason307await args.DeadLetterMessageAsync(308 args.Message,309 deadLetterReason: "ValidationFailed",310 deadLetterErrorDescription: "Missing required field: CustomerId");311312// Read from the dead-letter sub-queue313await using var dlqReceiver = client.CreateReceiver(314 "order-processing",315 new ServiceBusReceiverOptions316 {317 SubQueue = SubQueue.DeadLetter318 });319320while (true)321{322 var message = await dlqReceiver.ReceiveMessageAsync(323 TimeSpan.FromSeconds(5), cancellationToken);324 if (message is null) break;325326 logger.LogWarning(327 "DLQ message: {Reason} - {Description}",328 message.DeadLetterReason,329 message.DeadLetterErrorDescription);330331 // Inspect, fix, and re-submit or discard332 await dlqReceiver.CompleteMessageAsync(message);333}334335```text336337### MassTransit Error/Fault Queues338339MassTransit automatically creates `_error` and `_skipped` queues. Failed messages after retry exhaustion move to the340error queue with fault metadata.341342```csharp343344// Configure retry before dead-lettering345x.AddConsumer<OrderProcessor>(cfg =>346{347 cfg.UseMessageRetry(r => r.Intervals(348 TimeSpan.FromSeconds(1),349 TimeSpan.FromSeconds(5),350 TimeSpan.FromSeconds(15)));351});352353```text354355### DLQ Monitoring356357Always monitor DLQ depth with alerts. Unmonitored DLQs accumulate silently until data is lost or stale.358359---360361## Saga / Process Manager362363Sagas coordinate multi-step business processes across services. Each step publishes events that trigger the next step,364with compensation logic for failures.365366### Choreography vs Orchestration367368| Style | How it works | Use when |369| ----------------- | -------------------------------------------------------------- | ------------------------------------------------------- |370| **Choreography** | Services react to events independently; no central coordinator | Simple flows, few steps, loosely coupled |371| **Orchestration** | A saga/process manager directs each step | Complex flows, compensation needed, visibility required |372373### MassTransit State Machine Saga374375```csharp376377// Saga state378public class OrderState : SagaStateMachineInstance379{380 public Guid CorrelationId { get; set; }381 public string CurrentState { get; set; } = default!;382 public Guid OrderId { get; set; }383 public decimal Total { get; set; }384 public DateTime? PaymentReceivedAt { get; set; }385}386387// State machine definition388public sealed class OrderStateMachine : MassTransitStateMachine<OrderState>389{390 public State Submitted { get; private set; } = default!;391 public State PaymentPending { get; private set; } = default!;392 public State Completed { get; private set; } = default!;393 public State Faulted { get; private set; } = default!;394395 public Event<OrderSubmitted> OrderSubmitted { get; private set; } = default!;396 public Event<PaymentReceived> PaymentReceived { get; private set; } = default!;397 public Event<PaymentFailed> PaymentFailed { get; private set; } = default!;398399 public OrderStateMachine()400 {401402## Detailed Examples403404See [references/detailed-examples.md](references/detailed-examples.md) for complete code samples and advanced patterns.