Você salva o pedido no banco e, na linha seguinte, publica um evento OrderCreated na fila. Parece atômico — mas não é. Se a aplicação cair entre o SaveChanges() e o Publish(), o pedido existe e o evento nunca saiu. Se publicar primeiro e o commit falhar, o evento saiu para um pedido que não existe. Esse é o problema do dual-write, e é a razão de mensageria “bem-feita” ser mais do que chamar BasicPublish.

Este é um guia prático de mensageria com RabbitMQ no .NET 10, cobrindo o que separa um protótipo de um sistema confiável: publisher confirms, dead-letter queues, idempotência e o padrão Outbox. Todo o código usa a API assíncrona v7 do cliente (muita coisa que você acha no Google ainda é da v6 síncrona) e foi compilado e executado contra um RabbitMQ real.

Por que mensageria assíncrona

Filas desacoplam quem produz de quem consome. Em vez de a sua API chamar de forma síncrona três serviços (e ficar lenta e frágil quando um deles cai), ela publica um evento e segue. Os consumidores processam no seu ritmo, absorvem picos e podem tentar de novo em caso de falha. É a base de sistemas resilientes e escaláveis.

O RabbitMQ é um message broker maduro, e o padrão AMQP dá controle fino sobre entrega e confiabilidade — que é o que vamos explorar.

Subindo o RabbitMQ

Um container com a UI de administração resolve o ambiente local:

docker run -d --name rabbitmq \
  -p 5672:5672 -p 15672:15672 \
  rabbitmq:4-management
  • 5672 — porta AMQP (a aplicação conecta aqui).
  • 15672 — painel de administração em http://localhost:15672 (login padrão guest / guest).

Cliente raw vs. MassTransit. Frameworks como MassTransit e Wolverine automatizam muito do que veremos. Mas o MassTransit v9 passou a ser comercial em 2026 (gratuito só abaixo de US$ 1M de receita). Este guia usa o cliente RabbitMQ.Client puro — grátis, e didático justamente por mostrar as engrenagens.

Passo 1 — O pacote (e a virada v6 → v7)

dotnet add package RabbitMQ.Client

⚠️ A API mudou muito na v7 (a atual). Se você seguir um tutorial antigo, nada vai compilar. O que mudou:

  • IModel virou IChannel; CreateModel() virou CreateChannelAsync().
  • Tudo é assíncrono: QueueDeclareAsync, BasicPublishAsync, BasicAckAsync
  • O corpo da mensagem é ReadOnlyMemory<byte>, não byte[].
  • O consumidor é AsyncEventingBasicConsumer e o evento é ReceivedAsync (era Received).

Passo 2 — Produtor e consumidor mínimos (v7 async)

A conexão é cara e deve ser longa e compartilhada; o canal (IChannel) é barato. Um produtor:

using System.Text;
using RabbitMQ.Client;

var factory = new ConnectionFactory { HostName = "localhost" };
await using IConnection connection = await factory.CreateConnectionAsync();
await using IChannel channel = await connection.CreateChannelAsync();

await channel.ExchangeDeclareAsync("orders", ExchangeType.Topic, durable: true, autoDelete: false);

var body = Encoding.UTF8.GetBytes("""{ "orderId": 42 }""");
await channel.BasicPublishAsync(
    exchange: "orders",
    routingKey: "order.created",
    mandatory: true,
    basicProperties: new BasicProperties { Persistent = true },
    body: body);

E o consumidor:

using System.Text;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;

await using IChannel channel = await connection.CreateChannelAsync();

await channel.ExchangeDeclareAsync("orders", ExchangeType.Topic, durable: true, autoDelete: false);
await channel.QueueDeclareAsync("orders.queue", durable: true, exclusive: false, autoDelete: false);
await channel.QueueBindAsync("orders.queue", "orders", routingKey: "order.#");

var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (_, ea) =>
{
    var body = ea.Body.ToArray();   // copie ANTES de retornar — a memória é reutilizada
    var message = Encoding.UTF8.GetString(body);
    Console.WriteLine($"Recebido: {message}");
    await channel.BasicAckAsync(ea.DeliveryTag, multiple: false);
};

await channel.BasicConsumeAsync("orders.queue", autoAck: false, consumer: consumer);

Usamos um topic exchange: o produtor publica com uma routing key (order.created) e a fila liga com um padrão (order.# = qualquer evento de pedido). Note Persistent = true (mensagem sobrevive a restart do broker) e autoAck: false (vamos confirmar na mão).

Pegadinha: ea.Body é ReadOnlyMemory<byte> e pode ser liberado depois que o handler retorna. Copie com .ToArray() antes de qualquer await que devolva o controle.

Passo 3 — Produtor confiável: publisher confirms

Sem confirmação, BasicPublishAsync só entrega a mensagem ao socket — você não sabe se o broker realmente a aceitou. Os publisher confirms resolvem isso. Na v7, ative-os ao criar o canal:

using RabbitMQ.Client.Exceptions;   // PublishException vive aqui

var options = new CreateChannelOptions(
    publisherConfirmationsEnabled: true,
    publisherConfirmationTrackingEnabled: true);

await using IChannel channel = await connection.CreateChannelAsync(options);

try
{
    await channel.BasicPublishAsync(
        exchange: "orders", routingKey: "order.created", mandatory: true,
        basicProperties: new BasicProperties { Persistent = true },
        body: body);
    // chegou aqui = o broker CONFIRMOU o recebimento
}
catch (PublishException)
{
    // nack do broker, ou mensagem 'mandatory' sem fila de destino
    // trate: log, retry, ou deixe no Outbox para reenviar (Passo 5)
}

Com o tracking ligado, o await só completa quando o broker devolve o ack. Se ele recusar (nack) ou a mensagem mandatory não tiver rota, a Task lança PublishException. Repare que essa exceção está no namespace RabbitMQ.Client.Exceptions — sem esse using, não compila.

Passo 4 — Consumidor confiável: ack manual, prefetch e DLQ

Três ajustes transformam o consumidor ingênuo num consumidor de produção.

Prefetch (QoS) — sem isso, o RabbitMQ despeja todas as mensagens de uma vez no consumidor. Limite quantas ficam “em voo” sem ack:

await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false);

Dead-letter queue (DLQ) — mensagens “veneno” (que sempre falham) não podem ficar em loop infinito. Configure a fila principal para encaminhar rejeições a um dead-letter exchange:

// DLX + DLQ
await channel.ExchangeDeclareAsync("orders.dlx", ExchangeType.Fanout, durable: true, autoDelete: false);
await channel.QueueDeclareAsync("orders.dlq", durable: true, exclusive: false, autoDelete: false);
await channel.QueueBindAsync("orders.dlq", "orders.dlx", routingKey: "");

// Fila principal aponta para o DLX
var args = new Dictionary<string, object?> { ["x-dead-letter-exchange"] = "orders.dlx" };
await channel.QueueDeclareAsync("orders.queue", durable: true, exclusive: false, autoDelete: false, arguments: args);

Ack, nack e reenfileiramento — confirme só depois de processar com sucesso; decida o que fazer no erro:

consumer.ReceivedAsync += async (_, ea) =>
{
    try
    {
        await ProcessarAsync(ea.Body.ToArray());
        await channel.BasicAckAsync(ea.DeliveryTag, multiple: false);
    }
    catch (ErroTransienteException)
    {
        // falha temporária (rede, timeout) → reenfileira para nova tentativa
        await channel.BasicNackAsync(ea.DeliveryTag, multiple: false, requeue: true);
    }
    catch
    {
        // erro permanente (payload inválido) → NÃO reenfileira; vai para a DLQ
        await channel.BasicNackAsync(ea.DeliveryTag, multiple: false, requeue: false);
    }
};

Uma mensagem rejeitada com requeue: false é encaminhada ao x-dead-letter-exchange e para na orders.dlq, onde você pode inspecionar e reprocessar sem travar a fila principal.

Passo 5 — O problema do dual-write e o padrão Outbox

Voltamos ao começo. Este código parece correto e não é:

_db.Orders.Add(order);
await _db.SaveChangesAsync();               // (1) commit no banco
await _publisher.PublishAsync(evento);      // (2) publica na fila

Não existe transação atômica entre o Postgres e o RabbitMQ. Uma falha entre (1) e (2) — ou um nack em (2) — deixa banco e fila divergentes. O padrão Outbox resolve movendo o “publish” para dentro da transação do banco.

A ideia: grave o evento numa tabela outbox_messages na mesma transação que a mudança de negócio. Um worker separado lê os pendentes e publica no RabbitMQ. Como o evento e o dado de negócio entram no mesmo SaveChanges, ou os dois persistem ou nenhum.

A entidade:

public sealed class OutboxMessage
{
    public Guid Id { get; set; }
    public string Type { get; set; } = default!;      // ex.: "OrderCreated"
    public string Content { get; set; } = default!;   // payload JSON
    public DateTime OccurredOnUtc { get; set; }
    public DateTime? ProcessedOnUtc { get; set; }      // null = ainda não publicado
    public string? Error { get; set; }
}

A escrita atômica — negócio + evento no mesmo commit:

public async Task CreateOrderAsync(Order order, CancellationToken ct)
{
    _db.Orders.Add(order);

    _db.OutboxMessages.Add(new OutboxMessage
    {
        Id = Guid.NewGuid(),
        Type = nameof(OrderCreated),
        Content = JsonSerializer.Serialize(new OrderCreated(order.Id, order.Total)),
        OccurredOnUtc = DateTime.UtcNow
    });

    await _db.SaveChangesAsync(ct);   // um SaveChanges = uma transação = atômico
}

O worker publicador, um BackgroundService que faz polling e usa publisher confirms:

public sealed class OutboxPublisher(IServiceScopeFactory scopeFactory, IConnection connection)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        var options = new CreateChannelOptions(
            publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true);
        await using IChannel channel = await connection.CreateChannelAsync(options, ct);
        await channel.ExchangeDeclareAsync("orders", ExchangeType.Topic, durable: true,
            autoDelete: false, cancellationToken: ct);

        while (!ct.IsCancellationRequested)
        {
            using var scope = scopeFactory.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

            var pendentes = await db.OutboxMessages
                .Where(m => m.ProcessedOnUtc == null)
                .OrderBy(m => m.OccurredOnUtc)
                .Take(50)
                .ToListAsync(ct);

            foreach (var msg in pendentes)
            {
                try
                {
                    var props = new BasicProperties
                    {
                        Persistent = true,
                        MessageId = msg.Id.ToString(),   // usado para idempotência no consumidor
                        Type = msg.Type
                    };
                    await channel.BasicPublishAsync("orders", "order.created", mandatory: true,
                        basicProperties: props, body: Encoding.UTF8.GetBytes(msg.Content),
                        cancellationToken: ct);

                    msg.ProcessedOnUtc = DateTime.UtcNow;   // só marca APÓS a confirmação do broker
                }
                catch (Exception ex)
                {
                    msg.Error = ex.Message;   // fica pendente → nova tentativa no próximo ciclo
                }
            }

            await db.SaveChangesAsync(ct);
            await Task.Delay(TimeSpan.FromSeconds(1), ct);
        }
    }
}

Com isso, o evento nunca se perde: se o publish falhar, a linha continua ProcessedOnUtc == null e é retentada. A contrapartida é que o Outbox entrega at-least-once — a mesma mensagem pode chegar mais de uma vez. O que nos leva ao último passo.

Passo 6 — Idempotência no consumidor

Como a entrega é at-least-once, o consumidor precisa ser idempotente: processar a mesma mensagem duas vezes não pode gerar dois efeitos. Deduplique pelo MessageId:

consumer.ReceivedAsync += async (_, ea) =>
{
    var messageId = ea.BasicProperties.MessageId;

    // tenta registrar o id numa tabela de processados (PK = messageId),
    // na MESMA transação do efeito de negócio
    if (await _dedup.JaProcessadoAsync(messageId))
    {
        await channel.BasicAckAsync(ea.DeliveryTag, multiple: false);   // duplicata: só confirma
        return;
    }

    await ProcessarEMarcarComoProcessadoAsync(messageId, ea.Body.ToArray());
    await channel.BasicAckAsync(ea.DeliveryTag, multiple: false);
};

Uma tabela processed_messages(message_id PK): ao consumir, insira o id junto com o efeito de negócio numa transação. Se o id já existir (violação de PK), é reprocessamento — confirme e ignore. Assim, duplicatas viram no-op.

Hospedagem em ASP.NET Core

Registre a conexão como singleton (é cara e thread-safe) e cada consumidor/worker como BackgroundService com o seu próprio canal (canais não são thread-safe para publicação concorrente):

builder.Services.AddSingleton<IConnection>(_ =>
{
    var factory = new ConnectionFactory { HostName = builder.Configuration["RabbitMq:Host"] ?? "localhost" };
    return factory.CreateConnectionAsync().GetAwaiter().GetResult();
});

builder.Services.AddHostedService<OutboxPublisher>();   // publica os eventos do outbox
builder.Services.AddHostedService<OrderConsumer>();     // consome (cria o próprio canal)

Nunca abra uma conexão por mensagem — é caríssimo e explicitamente desencorajado pela documentação oficial.

Como fazemos na DevPlus

Nos nossos sistemas (o FiscalKeep é o caso mais intenso), a mensageria segue este espírito, com algumas convenções próprias:

  • Um pacote interno (Devplus.Messaging) encapsula o RabbitMQ.Client, padroniza os envelopes em CloudEvents 1.0 e cuida da topologia e do dead-lettering automático por consumidor.
  • Exchanges do tipo topic no padrão {produto}.{tópico}, filas dedicadas e consumidores como adapters finos em projetos Worker separados — a lógica de verdade fica na camada de Application.
  • Um switch por configuração troca o broker por um bus em memória (System.Threading.Channels) em desenvolvimento, então dá para rodar sem subir RabbitMQ.

E, sendo honesto sobre a evolução: boa parte do nosso código ainda faz save-then-publish com serviços de reconciliação (workers que varrem o banco e reprocessam o que ficou para trás) em vez do Outbox transacional. Funciona, mas o Outbox — como neste guia — é o caminho que recomendamos para novos serviços, porque move a garantia para dentro da transação em vez de depender de reconciliação.

Conclusão

Publicar uma mensagem é uma linha de código. Não perder uma mensagem é arquitetura: publisher confirms para saber que o broker aceitou, DLQ para isolar o que falha, idempotência porque a entrega é at-least-once, e o Outbox para eliminar o dual-write. Com essas quatro peças, a sua mensageria deixa de ser “na esperança” e passa a ter garantias.

Precisa de uma arquitetura orientada a eventos confiável em .NET? A DevPlus projeta e opera sistemas distribuídos com RabbitMQ em produção. Fale com a gente.

Veja também: Health Checks no ASP.NET Core: do básico ao Kubernetes, Clean Architecture no .NET do zero e Refit + Polly: chamadas HTTP resilientes em C#.