20 min readMehdi Hadeli

Wolverine Transactional Messaging with .NET Aspire, RabbitMQ, PostgreSQL, MongoDB, Inbox, Outbox, and Durable Local Processing

Introduction

Reliable messaging usually breaks in the dullest part of the request flow.

The domain write succeeds, but the process crashes before the event leaves the service. A broker redelivers a message, and the consumer applies the same change twice. An internal post-commit projection needs retries and durability, so the team ends up building an outbox table, an inbox table, a background worker, retry logic, and operational dashboards just to move data from one store to another.

This sample shows a smaller way to solve those problems with Wolverine and .NET Aspire. The example uses two services, PostgreSQL for durable messaging and write models, MongoDB for a read model, and a broker selected through configuration. The same service code can run against RabbitMQ or Kafka by changing Messaging:Transport.

Sample code: wolverine-transactional-messaging-aspire

Objectives

By the end of this article, you should be able to:

  1. Model a two-service sample around write-side persistence, integration events, and internal post-commit processing.
  2. Configure Wolverine with PostgreSQL durability and EF Core transaction integration.
  3. Publish broker messages through a transactional outbox.
  4. Use durable local processing for an internal MongoDB read-model projection.
  5. Consume broker messages with durable inbox semantics.
  6. Configure global retry policies and dead-letter queue behavior for RabbitMQ and Kafka listeners.
  7. Keep transport-specific code thin enough to switch between RabbitMQ and Kafka without forking the service design.

Sample Overview

The sample contains two microservices.

Catalogs is the write-side service.

  • It stores product aggregates in PostgreSQL.
  • It publishes MessageEnvelope<ProductCreatedV1> to RabbitMQ or Kafka based on configuration.
  • It sends an internal ProjectProductReadModel command through Wolverine so the MongoDB read model updates only after the PostgreSQL transaction commits.

Orders is the downstream consumer.

  • It listens to the same integration event from RabbitMQ or Kafka based on configuration.
  • It uses Wolverine durable inbox behavior backed by PostgreSQL.
  • It applies configurable retry policies and native dead-letter queues.
  • It stores an imported product record in its own PostgreSQL database.

The reusable pieces live in a small set of shared and building-block projects:

  • src/Services/Shared contains contracts, messaging constants, and the reusable MessageEnvelope<T>.
  • src/BuildingBlocks/BuildingBlocks.Integration.Wolverine* contains the sample Wolverine event bus, durable persistence service, and transport helpers.
  • src/BuildingBlocks/BuildingBlocks.Persistence.* contains the PostgreSQL and MongoDB setup the sample needs.
  • tests/Shared/Tests.Shared contains shared integration-test fixtures and sample test infrastructure.

Everything else was trimmed on purpose so the focus stays on Wolverine messaging behavior.

Architecture Overview

The AppHost provisions PostgreSQL, MongoDB, and a broker. Both APIs receive PostgreSQL, MongoDB where needed, and the selected broker connection details through Aspire resource references.

There are three distinct messaging concerns in the sample:

  1. External broker publishing from Catalogs.
  2. Internal durable processing inside Catalogs.
  3. Durable broker consumption in Orders.

Wolverine covers all three with one programming model. The next section reviews how each capability is set up according to the Wolverine docs, and the sections after that show how the sample wires them together.

Rendering diagram...

Wolverine Feature Setup

The sample builds on eight Wolverine capabilities. Here is how each is configured in isolation, with a link to the official docs.

1. Durable messaging with PostgreSQL

Wolverine stores message envelopes in PostgreSQL so that outgoing and internal messages survive process restarts. Add PersistMessagesWithPostgresql(connectionString) to WolverineOptions. This creates Wolverine envelope tables in the same database as the application data and enables the outbox, inbox, and durable local queue behavior used later.

Minimal Wolverine setup:

using Wolverine;
using Wolverine.Postgresql;

builder.Host.UseWolverine(options =>
{
    options.PersistMessagesWithPostgresql(connectionString);
});

Docs: Durable messaging

In the sample, the same call lives in AddWolverineMessaging:

hostBuilder.UseWolverine(options =>
{
    options.PersistMessagesWithPostgresql(
        integrationOptions.DurableStorageConnectionString
    );
    // ...
});

2. EF Core transaction integration

Call UseEntityFrameworkCoreTransactions(). Wolverine middleware opens an EF Core transaction around message handlers and HTTP endpoints that use Wolverine. When you publish or enqueue a message inside that boundary, Wolverine writes the envelope to the same transaction as the business data.

Minimal Wolverine setup:

using Wolverine.EntityFrameworkCore;

options.UseEntityFrameworkCoreTransactions();

Docs: Transactional middleware with EF Core

The sample turns this on conditionally in AddWolverineMessaging:

if (integrationOptions.Bus.UseEntityFrameworkCoreTransactions)
{
    options.UseEntityFrameworkCoreTransactions();
}

3. Transactional outbox

Inside a handler or endpoint, publish a message through Wolverine while an EF Core transaction is open. Wolverine writes the envelope to the outbox table, an implementation of the at-least-once delivery pattern. After the transaction commits, the persisted envelopes are delivered to the configured transport.

Minimal Wolverine setup with manual outbox enrollment:

await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);

outbox.Enroll(dbContext);
await bus.PublishAsync(message, cancellationToken);

await transaction.CommitAsync(cancellationToken);
await outbox.FlushOutgoingMessagesAsync();

Docs: Durable messaging

The sample uses the same pattern in CreateProductEndpoint:

await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);

outbox.Enroll(dbContext);
dbContext.Products.Add(product);
await dbContext.SaveChangesAsync(cancellationToken);

await externalEventBus.PublishAsync(integrationEvent, cancellationToken);
await messagePersistence.EnqueueLocalAsync(new ProjectProductReadModel(...), cancellationToken);

await transaction.CommitAsync(cancellationToken);
await outbox.FlushOutgoingMessagesAsync();

4. Durable local queues

Call UseDurableLocalQueues(). Commands sent to a local queue are written to PostgreSQL and processed by the same process, a form of internal processing. This gives retry semantics and survives restarts without adding an external broker for internal work.

Minimal Wolverine setup:

options.Policies.UseDurableLocalQueues();

Docs: Durable local queues

The sample turns this on for Catalogs in AddWolverineMessaging:

if (integrationOptions.Bus.UseDurableLocalQueues)
{
    options.Policies.UseDurableLocalQueues();
}

And enqueues the projection command inside the create-product transaction in CreateProductEndpoint:

await messagePersistence.EnqueueLocalAsync(
    new ProjectProductReadModel(...),
    cancellationToken
);

5. RabbitMQ transport

Call UseRabbitMqTransport(connectionString) to enable RabbitMQ. Map outgoing messages to a queue with PublishMessage<T>().ToRabbitQueue(name) and listeners with ListenToRabbitQueue(name).

Minimal Wolverine setup:

using Wolverine.RabbitMQ;

options.UseRabbitMqTransport(rabbitConnectionString)
       .PublishMessage<MyEvent>().ToRabbitQueue("my-queue")
       .ListenToRabbitQueue("my-queue");

Docs: RabbitMQ integration

The sample wraps publish and listen helpers in extension methods. Publishing is in WolverineOptionsRabbitMqExtensions:

options.UseRabbitMqTransport(
    "rabbitmq",
    rabbitMq =>
        rabbitMq.PublishToRabbitQueue<MessageEnvelope<ProductCreatedV1>>(
            MessagingConstants.ProductCreatedQueue
        )
);

Listening is in the same file:

options.UseRabbitMqTransport(
    "rabbitmq",
    rabbitMq =>
        rabbitMq.ListenToRabbitQueueTransport(
            MessagingConstants.ProductCreatedQueue
        )
);

6. Kafka transport

Call UseKafkaTransport(config) to enable Kafka. Map outgoing messages to a topic with PublishMessage<T>().ToKafkaTopic(topic) and listeners with ListenToKafkaTopic(topic, consumerGroup).

Minimal Wolverine setup:

using Wolverine.Kafka;

options.UseKafkaTransport(kafkaConfig)
       .PublishMessage<MyEvent>().ToKafkaTopic("my-topic")
       .ListenToKafkaTopic("my-topic", "my-consumer-group");

Docs: Kafka integration

The sample wraps publish and listen helpers in extension methods. Publishing is in WolverineOptionsKafkaExtensions:

options.UseKafkaTransport(
    "kafka",
    kafka =>
        kafka.PublishToKafkaTopic<MessageEnvelope<ProductCreatedV1>>(
            MessagingConstants.ProductCreatedTopic
        )
);

Listening is in the same file:

options.UseKafkaTransport(
    "kafka",
    kafka =>
        kafka.ListenToKafkaTopicTransport(
            MessagingConstants.ProductCreatedTopic,
            MessagingConstants.OrdersProductsConsumerGroup
        )
);

7. Durable inbox

Call UseDurableInboxOnAllListeners(). Incoming broker deliveries are tracked in PostgreSQL so Wolverine can apply exactly-once delivery inbox semantics instead of treating every redelivery as new work.

Minimal Wolverine setup:

options.Policies.UseDurableInboxOnAllListeners();

Docs: Durable messaging

The sample turns this on for Orders by default through configuration in ApplicationConfiguration.cs, then applies it inside AddWolverineMessaging:

var useDurableInboxOnAllListeners =
    builder.Configuration.GetSection("Wolverine")
        .GetValue<bool?>(nameof(WolverineBusOptions.UseDurableInboxOnAllListeners))
    ?? true;

// ...

UseDurableInboxOnAllListeners = useDurableInboxOnAllListeners;

// ...

if (integrationOptions.Bus.UseDurableInboxOnAllListeners)
{
    options.Policies.UseDurableInboxOnAllListeners();
}

8. Retry policies and dead-letter queues

Add a global retry policy so failed messages are retried before Wolverine gives up and routes them to an error or dead-letter destination.

Minimal Wolverine setup:

using Wolverine.ErrorHandling;

options
    .OnException<Exception>()
    .RetryTimes(2)
    .Then.MoveToErrorQueue();

That retries the message twice after the first failure, for a total of three attempts, then moves it to the error queue.

Docs: Error handling

The sample applies this policy from WolverineBusOptions.Retry in AddWolverineMessaging:

if (integrationOptions.Bus.Retry is { MaximumAttempts: > 0 })
{
    var immediateRetries = integrationOptions.Bus.Retry.MaximumAttempts - 1;
    if (immediateRetries > 0)
    {
        options
            .OnException<Exception>()
            .RetryTimes(immediateRetries)
            .Then.MoveToErrorQueue();
    }
    else
    {
        options.OnException<Exception>().MoveToErrorQueue();
    }
}

The retry count is controlled from configuration:

{
  "Wolverine": {
    "Retry": {
      "MaximumAttempts": 3
    }
  }
}

MaximumAttempts defaults to 3, so the sample keeps Wolverine's normal retry behavior unless you override it.

The interesting part starts after retries are exhausted.

  • RabbitMQ uses native dead-lettering by default. The sample can override the dead-letter queue name through WolverineBusOptions.DeadLetterQueueName.
  • Kafka needs an explicit opt-in on the listener, which the sample does with EnableNativeDeadLetterQueue().
  • Both transports share the same UseNativeDeadLetterQueue and DeadLetterQueueName options, so the application-level configuration stays stable while the transport-specific wiring stays small.

RabbitMQ transport setup in the sample:

public static WolverineOptions UseRabbitMqTransport(
    this WolverineOptions options,
    string connectionName,
    Action<WolverineOptions> configure,
    WolverineBusOptions? busOptions = null
)
{
    var transport = options.UseRabbitMqUsingNamedConnection(connectionName).AutoProvision();

    if (!string.IsNullOrWhiteSpace(busOptions?.DeadLetterQueueName))
    {
        transport.CustomizeDeadLetterQueueing(
            new DeadLetterQueue(busOptions.DeadLetterQueueName)
        );
    }

    configure(options);
    return options;
}

RabbitMQ listener with the optional dead-letter queue configuration:

options.UseRabbitMqTransport(
    "rabbitmq",
    rabbitMq =>
        rabbitMq.ListenToRabbitQueueTransport(
            MessagingConstants.ProductCreatedQueue,
            busOptions
        ),
    busOptions
);

Kafka transport setup in the sample:

public static KafkaListenerConfiguration ListenToKafkaTopicTransport(
    this WolverineOptions options,
    string topicName,
    string consumerGroupId,
    WolverineBusOptions? busOptions = null
)
{
    var listener = options
        .ListenToKafkaTopic(topicName)
        .UseDurableInbox()
        .ConfigureConsumer(config =>
        {
            config.GroupId = consumerGroupId;
        });

    if (busOptions?.UseNativeDeadLetterQueue != false)
    {
        listener.EnableNativeDeadLetterQueue();
    }
    else
    {
        listener.DisableNativeDeadLetterQueue();
    }

    return listener;
}

Kafka listener with native dead-letter queue enabled:

options.UseKafkaTransport(
    "kafka",
    kafka =>
        kafka.ListenToKafkaTopicTransport(
            MessagingConstants.ProductCreatedTopic,
            MessagingConstants.OrdersProductsConsumerGroup,
            busOptions
        ),
    busOptions
);

The options are bound from the Wolverine configuration section:

public sealed class WolverineBusOptions
{
    public bool ConfigureRabbitMqTopology { get; set; }
    public bool UseDurableInboxOnAllListeners { get; set; }
    public bool UseDurableLocalQueues { get; set; } = true;
    public bool UseEntityFrameworkCoreTransactions { get; set; } = true;
    public bool UseNativeDeadLetterQueue { get; set; } = true;
    public string? DeadLetterQueueName { get; set; }
    public WolverineRetryOptions Retry { get; set; } = new();
}

That gives the sample one place to express retry policy and one place to express transport-specific dead-letter behavior.

Proving Retry and Dead-Letter Behavior

The sample now includes focused integration tests for both RabbitMQ and Kafka dead-letter flows.

There is one subtle point worth calling out. Orders normally uses durable inbox semantics because that is the right default for production message consumption. For the dead-letter tests, the factory temporarily disables UseDurableInboxOnAllListeners so the tests can observe the broker-native dead-letter queue or topic directly instead of only Wolverine's durable inbox behavior.

The RabbitMQ test sets that override, starts the app host, waits for Wolverine to provision the queue, publishes a message that the handler is designed to fail, then polls the dead-letter queue. The snippet below is shortened to the essential flow:

protected override void ConfigureFactory(CustomWebApplicationFactory<Program> factory)
{
    base.ConfigureFactory(factory);
    factory.WithSetting("Wolverine:UseDurableInboxOnAllListeners", "false");
}

[Fact]
public async Task FaultyRabbitMqMessage_ShouldBeMovedToDeadLetterQueue()
{
    using var client = Factory.CreateClient();

    await WaitForRabbitMqQueueAsync(
        MessagingConstants.ProductCreatedQueue,
        TimeSpan.FromSeconds(30)
    );

    await channel.BasicPublishAsync(
        exchange: string.Empty,
        routingKey: MessagingConstants.ProductCreatedQueue,
        mandatory: false,
        basicProperties: properties,
        body: body
    );

    var deadLetterCount = await WaitForRabbitMqDeadLetterMessageAsync(
        messageId,
        TimeSpan.FromSeconds(30)
    );

    Assert.True(deadLetterCount > 0);
}

The Kafka test follows the same idea, but it waits for both the source topic and the dead-letter topic, gives the listener time to warm up, then produces the failing message and consumes it back from the dead-letter topic. This one is also shortened to the important steps:

protected override void ConfigureFactory(CustomWebApplicationFactory<Program> factory)
{
    base.ConfigureFactory(factory);
    factory.WithSetting("Wolverine:UseDurableInboxOnAllListeners", "false");
}

[Fact]
public async Task FaultyKafkaMessage_ShouldBeMovedToDeadLetterTopic()
{
    using var client = Factory.CreateClient();

    await WaitForKafkaTopicAsync(
        MessagingConstants.ProductCreatedTopic,
        TimeSpan.FromSeconds(30)
    );
    await WaitForKafkaTopicAsync(
        MessagingConstants.DeadLetterQueueName,
        TimeSpan.FromSeconds(30)
    );
    await Task.Delay(TimeSpan.FromSeconds(10));

    await producer.ProduceAsync(
        MessagingConstants.ProductCreatedTopic,
        new Message<Null, string> { Value = JsonSerializer.Serialize(envelope) }
    );

    var deadLetterCount = await WaitForKafkaDeadLetterMessageAsync(
        messageId,
        TimeSpan.FromSeconds(45)
    );

    Assert.True(deadLetterCount > 0);
}

Those tests are useful beyond basic coverage because they show the two operational details people usually miss the first time:

  1. The application host has to be started before you assume Wolverine has provisioned queues or topics.
  2. Broker-native dead-letter verification is not the same thing as testing durable inbox semantics.

Official Wolverine docs for this area:

Prerequisites

You need:

  1. .NET 10 SDK
  2. Docker Desktop or another supported OCI runtime
  3. A recent .NET Aspire workload and tooling setup

AppHost Infrastructure

The infrastructure entry point is AppHost.cs.

The AppHost does four useful things:

  1. Provisions one PostgreSQL server and creates catalogsdb and ordersdb.
  2. Provisions one MongoDB server and creates separate databases for each service.
  3. Provisions either RabbitMQ or Kafka based on Messaging__Transport.
  4. Passes the selected transport to both APIs.

That transport switch stays small:

var transport =
    builder.Configuration["Messaging:Transport"]?.Trim().ToLowerInvariant() ?? "rabbitmq";

switch (transport)
{
    case "rabbitmq":
        var rabbitMq = builder.AddRabbitMQ("rabbitmq");
        catalogsApi.WithReference(rabbitMq);
        ordersApi.WithReference(rabbitMq);
        break;

    case "kafka":
        var kafka = builder.AddKafka("kafka");
        catalogsApi.WithReference(kafka);
        ordersApi.WithReference(kafka);
        break;
}

That keeps the orchestration layer transport-aware without forcing the service code into two separate designs.

The ECommerce.ServiceDefaults project follows the same idea as the Aspire Shop defaults project: centralize OpenTelemetry wiring, service discovery, resilient HTTP defaults, and health endpoints once, then reuse that baseline from each service.

Shared Messaging Contracts

The shared contracts live under src/Services/Shared.

The key integration event is ProductCreatedV1.cs, but the more reusable part is the envelope abstraction in MessageEnvelope.cs.

public sealed record MessageEnvelope<TMessage>(
    Guid MessageId,
    Guid CorrelationId,
    DateTime OccurredAtUtc,
    TMessage Message
)
    where TMessage : IMessage;

That envelope gives the sample stable metadata for correlation, tracing, and test fixtures without pushing those fields into every event type.

Transport names stay in one place inside MessagingConstants.cs:

  • catalogs-products-created queue name for RabbitMQ
  • catalogs-products-created topic name for Kafka
  • orders-products consumer group name for Kafka

The active broker is resolved through Messaging:Transport, which the services read with a typed helper in MessagingTransportConfigurationExtensions.cs. The AppHost uses the same key to provision the right broker container.

Configuring Wolverine in Catalogs

The main write-side Wolverine configuration lives in ApplicationConfiguration.cs.

builder.Host.AddWolverineMessaging(
    new WolverineIntegrationOptions
    {
        DurableStorageConnectionString = connectionString,
        RabbitMqConnectionString = rabbitMqConnectionString,
        Bus = new WolverineBusOptions
        {
            ConfigureRabbitMqTopology = transport == MessagingTransportType.RabbitMq,
            UseDurableLocalQueues = busOptions.UseDurableLocalQueues,
            UseEntityFrameworkCoreTransactions =
                busOptions.UseEntityFrameworkCoreTransactions,
        },
    },
    options =>
    {
        options.Discovery.IncludeAssembly(typeof(ApplicationConfiguration).Assembly);

        switch (transport)
        {
            case MessagingTransportType.RabbitMq:
                options.UseRabbitMqTransport(
                    "rabbitmq",
                    rabbitMq =>
                        rabbitMq.PublishToRabbitQueue<MessageEnvelope<ProductCreatedV1>>(
                            MessagingConstants.ProductCreatedQueue
                        )
                );
                break;

            case MessagingTransportType.Kafka:
                options.UseKafkaTransport(
                    "kafka",
                    kafka =>
                        kafka.PublishToKafkaTopic<MessageEnvelope<ProductCreatedV1>>(
                            MessagingConstants.ProductCreatedTopic
                        )
                );
                break;
        }
    }
);

Three lines matter most in practice:

  1. PersistMessagesWithPostgresql(connectionString)
  2. UseEntityFrameworkCoreTransactions()
  3. UseDurableLocalQueues()

Together they tell Wolverine to:

  • store durable envelopes in PostgreSQL
  • participate in the same transaction boundary as EF Core
  • persist internal asynchronous commands instead of treating them as in-memory work

The shared WolverineBusOptions defaults assume you want durability unless you opt out:

public sealed class WolverineBusOptions
{
    public bool ConfigureRabbitMqTopology { get; set; }
    public bool UseDurableInboxOnAllListeners { get; set; }
    public bool UseDurableLocalQueues { get; set; } = true;
    public bool UseEntityFrameworkCoreTransactions { get; set; } = true;
}

Catalogs keeps the defaults, so EF Core transaction integration and durable local queues both stay on. Orders disables durable local queues and enables UseDurableInboxOnAllListeners instead, because its only messaging job is to consume broker events durably; it does not enqueue local projection commands.

The sample integration layer also splits responsibilities more clearly:

  1. IExternalEventBus for application-facing external event publishing
  2. IMessagePersistenceService for durable publish and durable local enqueue
  3. transport-specific RabbitMQ and Kafka registration helpers

Transactional Outbox in the Create Product Slice

The business flow lives in CreateProductEndpoint.cs.

This is the core sequence:

await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);

outbox.Enroll(dbContext);
dbContext.Products.Add(product);
await dbContext.SaveChangesAsync(cancellationToken);

await externalEventBus.PublishAsync(integrationEvent, cancellationToken);
await messagePersistence.EnqueueLocalAsync(new ProjectProductReadModel(...), cancellationToken);

await transaction.CommitAsync(cancellationToken);
await outbox.FlushOutgoingMessagesAsync();

This gives you a real transactional outbox flow.

  • The product write model and Wolverine durable envelopes are part of the same PostgreSQL transaction.
  • If the transaction rolls back, the outgoing integration event and the internal projection command roll back with it.
  • If the transaction commits, Wolverine can dispatch the external and internal messages afterward.

That is the gap most hand-rolled messaging designs struggle to close cleanly.

Rendering diagram...

Durable Internal Processing for MongoDB Read Models

Kamil Grzybek's modular monolith write-up has a good explanation of internal processing: handling an in-process command after the domain transaction commits so downstream work stays consistent without relying on an external broker.

The internal command is ProjectProductReadModel.cs.

The handler is ProjectProductReadModelHandler.cs, and it writes through MongoProductReadRepository.cs.

The handler stays small:

public static Task Handle(
    ProjectProductReadModel command,
    IProductReadRepository repository,
    CancellationToken cancellationToken
)
{
    return repository.UpsertAsync(
        new ProductReadModel(
            command.ProductId,
            command.Code,
            command.Name,
            command.Price,
            command.CreatedAtUtc,
            DateTime.UtcNow
        ),
        cancellationToken
    );
}

That simplicity works because Wolverine already gives the command durable delivery semantics. You do not need a separate polling worker to keep the MongoDB projection retryable.

Durable Inbox in Orders

The consumer-side configuration lives in ApplicationConfiguration.cs.

builder.Host.AddWolverineMessaging(
    new WolverineIntegrationOptions
    {
        DurableStorageConnectionString = connectionString,
        RabbitMqConnectionString = rabbitMqConnectionString,
        Bus = new WolverineBusOptions
        {
            ConfigureRabbitMqTopology = transport == MessagingTransportType.RabbitMq,
            UseDurableLocalQueues = false,
            UseDurableInboxOnAllListeners = useDurableInboxOnAllListeners,
            UseEntityFrameworkCoreTransactions =
                busOptions.UseEntityFrameworkCoreTransactions,
        },
    },
    options =>
    {
        options.Discovery.IncludeAssembly(typeof(ApplicationConfiguration).Assembly);

        switch (transport)
        {
            case MessagingTransportType.RabbitMq:
                options.UseRabbitMqTransport(
                    "rabbitmq",
                    rabbitMq =>
                        rabbitMq.ListenToRabbitQueueTransport(
                            MessagingConstants.ProductCreatedQueue,
                            busOptions
                        ),
                    busOptions
                );
                break;

            case MessagingTransportType.Kafka:
                options.UseKafkaTransport(
                    "kafka",
                    kafka =>
                        kafka.ListenToKafkaTopicTransport(
                            MessagingConstants.ProductCreatedTopic,
                            MessagingConstants.OrdersProductsConsumerGroup,
                            busOptions
                        ),
                    busOptions
                );
                break;
        }
    }
);

The key policy is UseDurableInboxOnAllListeners().

That means broker deliveries are tracked in PostgreSQL so Wolverine can apply exactly-once delivery inbox semantics instead of treating every redelivery as new work. The actual handler is ProductCreatedHandler.cs, which upserts an imported product record from the event payload.

UseNativeDeadLetterQueue and the Retry options are also active for Orders. When a message fails repeatedly, Wolverine retries it up to the configured maximum and then moves it to the transport's dead-letter destination: RabbitMQ's native dead-letter queue or Kafka's native dead-letter topic.

Transport Switching and Topology Provisioning

The sample contains both RabbitMQ and Kafka transport branches, and the active broker is selected through configuration instead of separate service implementations.

RabbitMQ and Kafka differ only at the transport edge:

  • RabbitMQ uses queue publishing and queue listeners.
  • Kafka uses topic publishing and topic listeners plus a consumer group.

The durability story stays the same:

  • PostgreSQL stores Wolverine durable envelopes.
  • EF Core transaction integration keeps the write model and envelopes aligned.
  • Durable local queues handle internal post-commit processing.
  • Durable inbox policies protect consumers.

At runtime, the switch is the Messaging:Transport setting in the service configuration or the Aspire environment override.

For RabbitMQ, the sample also pre-declares exchanges and queues through a hosted service in RabbitMqTopologyProvisioningHostedService.cs. When the transport is configured, the extension registers the topology before wiring up the Wolverine publisher:

public static WolverineOptions PublishToRabbitQueue<T>(this WolverineOptions options, string queueName)
{
    WolverineMessageTopologyExtensions.RegisterPublishTopology<T>(queueName, queueName);
    options.PublishMessage<T>().ToRabbitQueue(queueName);
    return options;
}

This keeps queue, exchange, and binding declarations in one place and lets the AppHost start without relying on Wolverine's auto-provisioning alone.

Message metadata travels through Wolverine headers. When a message implements IWolverineMessageEnvelope, WolverineDeliveryOptionsFactory.cs copies MessageId, CorrelationId, and OccurredAtUtc onto DeliveryOptions so the envelope metadata crosses the broker with the payload.

To run the sample with RabbitMQ:

Messaging__Transport=rabbitmq dotnet run --project src/Aspire/ECommerce.AppHost/ECommerce.AppHost.csproj

To run the same code path with Kafka:

Messaging__Transport=kafka dotnet run --project src/Aspire/ECommerce.AppHost/ECommerce.AppHost.csproj

Validation

Build the solution:

dotnet build wolverine-transactional-messaging-aspire.slnx

Run the tests:

dotnet test wolverine-transactional-messaging-aspire.slnx

The current test suite covers:

  • MessageEnvelope<T> metadata creation
  • durable internal MongoDB projection handler behavior
  • downstream consumer upsert behavior
  • integration startup tests that override Messaging:Transport to exercise both RabbitMQ and Kafka
  • RabbitMQ dead-letter integration coverage for failed broker messages
  • Kafka dead-letter integration coverage for failed broker messages
  • vertical-slice integration tests that keep one test class per feature and switch the broker through appsettings overrides

If you only want to run the dead-letter tests while reading this article:

dotnet test tests/Services/Orders/ECommerce.Services.Orders.IntegrationTests/ECommerce.Services.Orders.IntegrationTests.csproj --filter "FullyQualifiedName~DeadLetterQueueTests|FullyQualifiedName~KafkaDeadLetterQueueTests"

The shared integration test fixtures explicitly use the local Docker images already present in the sample environment:

  • postgres:17
  • rabbitmq:4-management
  • confluentinc/cp-kafka:7.5.12
  • mongo:8.0

The integration test structure now mirrors the application slices more closely:

  • each service keeps one shared integration-test base that has both RabbitMQ and Kafka fixtures available
  • the base replaces broker connection strings according to Messaging:Transport
  • startup tests verify broker selection with explicit appsettings overrides
  • feature tests stay under the same vertical-slice folders as the production code, such as Products/Features/CreatingProduct/v1 and Products/Features/GettingImportedProducts/v1

If you want to exercise the live flow manually, start the AppHost, open the Aspire dashboard, copy the catalogs-api base URL, and create a product:

curl -X POST "<catalogs-base-url>/api/v1/catalogs/products" \
  -H "Content-Type: application/json" \
  -d '{"code":"catalog-001","name":"Starter Basket","price":42.50}'

Then inspect:

  • GET <catalogs-base-url>/api/v1/catalogs/products/read-model
  • GET <orders-base-url>/api/v1/orders/products

The first endpoint shows the internal durable projection into MongoDB. The second shows the downstream broker-consumer result in the Orders service.

Project Structure

The sample keeps a feature-first structure without adding extra infrastructure layers that do not help explain the messaging flow.

Key folders:

  • src/Aspire/ECommerce.AppHost
  • src/Aspire/ECommerce.ServiceDefaults
  • src/BuildingBlocks/*
  • src/Services/Catalogs/*
  • src/Services/Orders/*
  • src/Services/Shared/*
  • tests/Shared/Tests.Shared
  • tests/Services/Catalogs/*
  • tests/Services/Orders/*

Inside each service, the features stay close to the endpoints and handlers that implement them. In Catalogs, that means slices such as:

  • Products/Features/CreatingProduct/v1/CreateProductEndpoint.cs
  • Products/Features/GettingProductById/v1/GetProductByIdEndpoint.cs
  • Products/Features/GettingProductReadModels/v1/GetProductReadModelsEndpoint.cs
  • Products/Features/ProjectingProductReadModel/v1/ProjectProductReadModelHandler.cs

On the Orders side, the same structure holds:

  • Products/Features/ConsumingProductCreated/v1/ProductCreatedHandler.cs
  • Products/Features/GettingImportedProducts/v1/GetImportedProductsEndpoint.cs
  • Products/Features/GettingImportedProductById/v1/GetImportedProductByIdEndpoint.cs

The test projects follow the same idea. Instead of separate test hierarchies per broker, each slice keeps a small number of tests and overrides Messaging:Transport when it needs to validate RabbitMQ or Kafka behavior.

That is enough structure to feel like a real microservice codebase without turning the sample into a framework demo.

Trade-offs

It helps to be explicit about the trade-offs:

  1. You are relying on Wolverine runtime behavior and durability tables instead of handwritten plumbing.
  2. Eventual consistency still means temporary lag between the PostgreSQL write model, MongoDB projection, and downstream service state.
  3. The sample is intentionally small, so it does not cover every operational concern such as richer contract versioning, full observability dashboards, or a large distributed compatibility matrix for every broker scenario.

Those trade-offs are reasonable here because the goal is clarity around transactional messaging, not production completeness in every direction.

Conclusion

This sample shows how to build two small services around one consistent messaging model.

Catalogs writes a product once, Wolverine persists the external and internal work transactionally, MongoDB is projected through durable local processing, and Orders consumes the event through durable inbox semantics. Aspire keeps the infrastructure reproducible, and the shared test layer now proves the same slices against broker selection through configuration without burying the point under a large amount of generic scaffolding.

If your current design still depends on hand-written outbox rows, custom pollers, and ad hoc retry logic, Wolverine is a strong candidate to remove that infrastructure burden while keeping the service code small and explicit.