Message Queue Patterns in .NET: A Practical Guide

distributed-systemsdotnetrabbitmqsoftware-architecture
Message Queue Patterns in .NET: A Practical Guide

Message queue patterns define who receives a message and whether the sender waits for a reply. In .NET, MassTransit supports point-to-point commands, publish/subscribe events, competing consumers, and request-reply over brokers such as RabbitMQ. These routing patterns still need durable publication, duplicate handling, and a recovery path for failed messages.

A queue lets the sender hand off work while a receiver processes it later. That is useful for background processing and traffic spikes, but it changes what a successful request means. Accepting a message does not prove that its business operation finished.

Which Pattern Fits the Work?

PatternDelivery intentExample
Point-to-pointOne owning endpoint handles a command; delivery can repeatGenerate an invoice
Publish/subscribeEach subscribed endpoint receives an eventReact to an order being placed
Competing consumersInstances share the work on one queueScale invoice processing
Request-replyThe caller waits for a response over the brokerAsk the inventory service for availability

Choose Send when a particular endpoint owns the command. Choose Publish when independently deployed subscribers should react to a fact. Neither operation makes the consumer's business effect happen exactly once.

Run the Patterns With MassTransit

The following .NET 10 console app uses MassTransit and a local RabbitMQ broker. It logs work instead of charging cards or sending email. It is a routing demonstration; the reliability sections below describe what a real business operation needs.

The package is pinned to MassTransit 8.5.11. Review the MassTransit version and licensing notes before changing its major version.

Create the project:

dotnet new console -n QueuePatterns -f net10.0
cd QueuePatterns
dotnet add package MassTransit.RabbitMQ --version 8.5.11
dotnet add package Microsoft.Extensions.Hosting --version 10.0.0
docker run --rm --name queue-patterns-rabbit -p 127.0.0.1:5672:5672 rabbitmq:4-management

Leave the local broker running in a separate terminal. Create Messages.cs with contracts and consumers in a namespace:

using MassTransit;

namespace QueuePatterns;

public sealed record GenerateInvoice(Guid OrderId, decimal Amount);
public sealed record OrderCompleted(Guid OrderId, decimal Amount);
public sealed record CheckInventory(Guid ProductId, int Quantity);
public sealed record InventoryResult(bool IsAvailable, int AvailableQuantity);

public sealed class GenerateInvoiceConsumer : IConsumer<GenerateInvoice>
{
    public Task Consume(ConsumeContext<GenerateInvoice> context)
    {
        Console.WriteLine($"Invoice requested: {context.Message.OrderId}");
        return Task.CompletedTask;
    }
}

public sealed class EmailConsumer : IConsumer<OrderCompleted>
{
    public Task Consume(ConsumeContext<OrderCompleted> context)
    {
        Console.WriteLine($"Email subscriber: {context.Message.OrderId}");
        return Task.CompletedTask;
    }
}

public sealed class AnalyticsConsumer : IConsumer<OrderCompleted>
{
    public Task Consume(ConsumeContext<OrderCompleted> context)
    {
        Console.WriteLine($"Analytics subscriber: {context.Message.OrderId}");
        return Task.CompletedTask;
    }
}

public sealed class CheckInventoryConsumer : IConsumer<CheckInventory>
{
    public Task Consume(ConsumeContext<CheckInventory> context)
    {
        const int available = 10; // Fixed demo stock, not a reservation.
        var quantity = context.Message.Quantity;
        return context.RespondAsync(new InventoryResult(
            quantity > 0 && quantity <= available, available));
    }
}

Replace Program.cs with this host configuration and sender:

using MassTransit;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using QueuePatterns;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddMassTransit(x =>
{
    x.SetKebabCaseEndpointNameFormatter();
    x.AddConsumer<GenerateInvoiceConsumer>();
    x.AddConsumer<EmailConsumer>();
    x.AddConsumer<AnalyticsConsumer>();
    x.AddConsumer<CheckInventoryConsumer>();
    x.AddRequestClient<CheckInventory>(new Uri("queue:check-inventory"));

    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host(new Uri(builder.Configuration["RabbitMq"]
            ?? "rabbitmq://localhost/"));
        cfg.UseMessageRetry(r => r.Intervals(100, 500, 1000));
        cfg.ConfigureEndpoints(context);
    });
});
builder.Services.AddOptions<MassTransitHostOptions>()
    .Configure(options => options.WaitUntilStarted = true);

using var host = builder.Build();
await host.StartAsync();

await using (var scope = host.Services.CreateAsyncScope())
{
    var orderId = Guid.NewGuid();
    var sender = scope.ServiceProvider.GetRequiredService<ISendEndpointProvider>();
    var endpoint = await sender.GetSendEndpoint(new Uri("queue:generate-invoice"));
    await endpoint.Send(new GenerateInvoice(orderId, 42m));

    var publisher = scope.ServiceProvider.GetRequiredService<IPublishEndpoint>();
    await publisher.Publish(new OrderCompleted(orderId, 42m));

    var client = scope.ServiceProvider.GetRequiredService<IRequestClient<CheckInventory>>();
    var response = await client.GetResponse<InventoryResult>(
        new CheckInventory(Guid.NewGuid(), 2));
    Console.WriteLine($"Available: {response.Message.IsAvailable}");
}

Console.WriteLine("Press Enter after the consumer messages appear.");
Console.ReadLine();
await host.StopAsync();

Run dotnet run. The invoice consumer and both event subscribers log the same order ID. The request client prints Available: True. Subscriber output can arrive in any order.

Point-to-Point Commands

The GenerateInvoice command goes to the explicit generate-invoice endpoint. The kebab-case formatter removes the Consumer suffix, so that address matches the configured consumer queue:

A producer sends a command to one queue and a single consumer receives each queued message

One endpoint owns the command, but a delivery can be repeated after failure. Use a stable invoice or operation ID and a database uniqueness constraint to prevent duplicate invoices. For payments, pass the same idempotency key to the payment provider on every retry.

If saving the order and sending the command must succeed together, use an outbox. The console example has no business database, so it does not demonstrate that transaction boundary.

Publish/Subscribe Events

OrderCompleted is published without choosing a receiver. RabbitMQ routes it to each subscribed queue:

A publisher sending one event to an exchange that delivers a separate copy to subscriber A, subscriber B, and subscriber C

In this configuration, EmailConsumer and AnalyticsConsumer have different endpoint names and separate queues. A slow analytics subscriber does not occupy the email subscriber's consumer slots. Consumers configured on the same endpoint share a queue instead, so queue isolation is a configuration choice.

Publish after the subscriber topology exists. Events published before queues are bound are not automatically replayed to subscribers created later. The RabbitMQ pub/sub guide explains exchanges, bindings, and retained queues in more detail.

This is the routing foundation of event-driven architecture. Contract compatibility still couples producers and subscribers, even when they deploy independently.

Competing Consumers

Deploy another instance with the same endpoint names and broker namespace. Both instances consume from the same queues:

Three consumer instances sharing a queue to distribute delivery attempts, with redelivery still possible after a failure

To bound invoice concurrency, replace cfg.ConfigureEndpoints(context) in the transport configuration with this block:

cfg.ReceiveEndpoint("generate-invoice", e =>
{
    e.PrefetchCount = 16;
    e.ConcurrentMessageLimit = 8;
    e.ConfigureConsumer<GenerateInvoiceConsumer>(context);
});
cfg.ConfigureEndpoints(context);

MassTransit skips automatic endpoint creation for a consumer already configured manually. The other consumers still receive their automatic endpoints. The limits above apply per instance; adding instances increases total concurrency against downstream services.

This does not preserve completion order. One instance can finish a later delivery while another is still working on an earlier one. Use competing consumers to scale independent work and partition keys or sessions when the business requires per-entity sequencing.

Request-Reply

The request client sends CheckInventory to a specific queue and waits for InventoryResult:

Request-reply sequence where a client sends a request through a queue to the server, and the server replies over a temporary reply queue

This reintroduces a timing dependency. If the responder is unavailable, the caller waits until its timeout expires. A timeout does not necessarily cancel work already received by the responder.

The sample's stock check only reads a fixed quantity. Even a real availability read would not reserve inventory; another buyer could claim it before checkout. Use an atomic reservation operation if the business needs a guarantee.

Request-reply is useful when the participants already use the broker and need a response. For ordinary synchronous reads, HTTP or gRPC usually has fewer moving parts.

Failed Messages Need a Recovery Path

The sample retries a consumer exception three times, with delays of 100, 500, and 1,000 milliseconds. After those retries are exhausted, MassTransit moves the message to the endpoint's _error queue:

A consumer retries a failed message and moves it from the work queue to a dead letter queue after the retry budget is exhausted

This is MassTransit's error transport behavior. Raw RabbitMQ consumers do not gain a retry limit and error queue merely by declaring a queue. Configure those explicitly if you use the broker client directly.

Alert on error-queue growth and give someone responsibility for investigation and replay. Keep dead-letter recovery separate from transient retry policy, and make replay safe for messages whose external effects may already have happened.

Add Reliability Around the Routing

An outbox closes the gap between a database commit and sending a message. An idempotent consumer handles repeated deliveries without repeating the business effect. Neither guarantees that an external API behaves atomically with your database.

Bound in-process work with backpressure so a backlog remains in the broker instead of filling application memory. Track queue age as well as depth: a queue draining steadily can still miss a business deadline. For transport selection, compare RabbitMQ, Postgres queues, and NATS JetStream by routing, durability, and operating cost.

Summary

Use a queue when the caller can accept completion later or when independent subscribers need the same event. Keep local transactions local when one database can own the operation. Before releasing the consumer, exercise duplicate delivery, a dependency outage, and replay from its error queue.

Frequently Asked Questions

What are the main message queue patterns?

The core patterns are point-to-point (one owning endpoint, one receiver per delivery attempt), publish/subscribe (each subscribed endpoint gets a copy), competing consumers (instances share one queue), request-reply (the sender waits for a response), and dead letter queues for failed messages.

What is the difference between point-to-point and publish/subscribe?

Point-to-point sends work to a specific owning endpoint. Publish/subscribe routes an event to each subscribed endpoint. Either can redeliver after failure, so neither guarantees that a business effect happens exactly once.

When should I use a message queue instead of HTTP?

Use a queue when the caller does not need the result immediately: background processing, notifications, propagating events between services, or absorbing traffic spikes. Stick with HTTP when the user is waiting for the answer, such as reads and queries.

Do message queues guarantee exactly-once delivery?

Delivery and processing guarantees depend on the broker and configuration. Even deduplicated delivery does not make an external business effect happen exactly once. Consumers need atomic duplicate detection or an idempotency key for effects that can be retried.

What is a dead letter queue?

A dead letter queue is where the broker or framework moves messages that keep failing after all retries. It prevents a poison message from blocking the queue while preserving it for inspection, fixing, and replay.

  • Publish/Subscribe Pattern With RabbitMQ in .NET

    The Publish/Subscribe pattern decouples message producers from consumers. Here is how to implement it with RabbitMQ and MassTransit in .NET, including retries, the outbox pattern, and idempotent consumers.

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

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

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.