Featured image of post Wolverine: Transactional Messaging with RabbitMQ, Inbox, Outbox, and Durable Local Queues

Wolverine: Transactional Messaging with RabbitMQ, Inbox, Outbox, and Durable Local Queues

Learn how to use Wolverine with RabbitMQ in a two-microservice e-commerce sample to implement transactional outbox, durable inbox, and internal durable processing across PostgreSQL and MongoDB.

When we build microservices, the hard part is usually not publishing a message. The hard part is making database writes and message delivery behave correctly when failures happen between them. If a service stores data in PostgreSQL, publishes an integration event to RabbitMQ, and also needs to update another store like MongoDB, we need a reliable way to keep those steps consistent without using a distributed transaction.

In this post, I want to show a practical way to solve that problem with Wolverine. The sample uses a small e-commerce system with two microservices, Catalogs and Orders, and demonstrates three important patterns together:

  • transactional outbox for publishing integration events after the database commit
  • durable inbox for idempotent downstream message consumption
  • durable local processing for internal work that should run after commit in a separate transaction

The sample is based on my Wolverine transactional messaging structure and uses a two-service e-commerce scenario with Catalog and Order. In the local blog sample, I kept the same architectural boundaries so the article stays practical: shared contracts, separate service projects, an Aspire AppHost, and focused integration tests.

The Problem We Want to Solve

Assume Catalogs receives a request to create a product.

That request needs to do three things:

  1. Store the product in PostgreSQL.
  2. Publish ProductCreatedV1 so Orders can import that product.
  3. Update a MongoDB read model for query scenarios.

These three steps do not belong to one physical transaction:

  • PostgreSQL write uses one database transaction.
  • RabbitMQ publish is external broker communication.
  • MongoDB projection should happen in a different persistence boundary.

If we do these steps manually in sequence, failures create inconsistent state very quickly:

  • product saved in PostgreSQL but event not published
  • event published but local transaction rolled back
  • product saved and event published but MongoDB projection failed
  • downstream service receives duplicate messages and writes duplicate records

This is exactly where Wolverine helps.

What Wolverine Gives Us

Wolverine is not only a message bus. It also gives us durable messaging primitives that fit very well with transactional application code.

For this scenario, the important capabilities are:

  • durable outgoing messages for outbox behavior
  • durable incoming messages for inbox behavior
  • durable local queues for internal post-commit processing
  • EF Core integration so message persistence can participate in the same PostgreSQL transaction as the write model

That means we can persist application data and outgoing work together, commit once, and let Wolverine flush the durable work only after the transaction succeeds.

Sample Scenario

The sample uses two microservices in an e-commerce domain:

  • Catalogs: write-side service for creating products
  • Orders: downstream consumer that imports product data from integration events

The flow is:

  1. Catalogs receives POST /api/v1/catalogs/products.
  2. Catalogs stores the product in PostgreSQL.
  3. In the same transaction, Catalogs queues ProductCreatedV1 for RabbitMQ.
  4. In the same transaction, Catalogs also queues an internal durable command named ProjectProductReadModel.
  5. After commit, Wolverine dispatches both durable messages.
  6. The local durable handler projects the product into MongoDB.
  7. Orders consumes ProductCreatedV1 through a durable inbox and stores its own imported product record in PostgreSQL.

This gives us one transactional write boundary in Catalogs, one separate local processing boundary for MongoDB, and one separate consumer boundary in Orders.

Here is the sample API entry point that starts the flow:

1
2
3
4
5
6
7
8
app.MapPost("/api/v1/catalogs/products", async (CreateProductRequestDto request, CatalogService service, CancellationToken cancellationToken) =>
{
    var result = await service.CreateProductAsync(
        new CreateProductRequest(request.Name, request.Price, request.Stock, request.CorrelationId),
        cancellationToken);

    return Results.Accepted($"/api/v1/catalogs/products/{result.ProductId}", result);
});

Why Durable Local Processing Matters

This is the part many teams miss.

Sometimes we only think about external messaging, but internal asynchronous work can be just as important. In this sample, MongoDB projection is not an external integration event. It is internal application work, but it still should not run inside the same request transaction.

Why?

  • MongoDB is a different persistence technology.
  • We do not want the HTTP request to fail because a secondary projection store is temporarily unavailable.
  • We want the write model in PostgreSQL to stay the source of truth.
  • We still want reliable eventual consistency for the read model.

So instead of writing to MongoDB inline, Catalogs sends a durable local command after the PostgreSQL transaction is committed. That gives us a clean separation:

  • PostgreSQL write model is committed first.
  • MongoDB projection runs later in its own handler and its own persistence boundary.
  • If projection fails, Wolverine can retry it without losing the original business write.

This is what I mean by internal message processing in a transactional way. It is not one distributed transaction across PostgreSQL and MongoDB. It is a reliable post-commit workflow with durable local messaging.

Solution Structure

The sample keeps the structure close to a real microservice solution instead of putting everything in one demo project.

Main parts of the local sample:

  • src/Aspire/ECommerce.AppHost: Aspire orchestration entry point for catalogs-api, orders-api, RabbitMQ, PostgreSQL, and MongoDB containers
  • src/Services/Catalog/Catalog: write-side product logic and internal read-model projection flow
  • src/Services/Catalog/Catalog.Api: HTTP endpoints for creating products and reading write/read models
  • src/Services/Order/Order: downstream import logic with inbox-style duplicate protection
  • src/Services/Order/Order.Api: HTTP endpoints for importing and querying products in the order service
  • src/Shared/Contracts: MessageEnvelope<T>, ProductCreatedV1, and ProjectProductReadModel
  • tests/Shared/Tests.Shared: shared end-to-end flow helper used by service tests
  • tests/Services/Catalog/Catalog.Tests: integration test for create + project flow
  • tests/Services/Order/Order.Tests: integration test for idempotent import behavior

This structure matters because the article is not only about Wolverine APIs. It is also about where to place contracts, handlers, persistence, and feature slices in a maintainable solution.

The shared contracts are intentionally small and explicit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public sealed record MessageEnvelope<T>(
    Guid MessageId,
    DateTime CreatedAt,
    string CorrelationId,
    T Data)
{
    public static MessageEnvelope<T> Create(T data, string? correlationId = null)
    {
        return new MessageEnvelope<T>(
            Guid.NewGuid(),
            DateTime.UtcNow,
            correlationId ?? Guid.NewGuid().ToString("N"),
            data);
    }
}

public sealed record ProductCreatedV1(Guid ProductId, string Name, decimal Price, int Stock);

public sealed record ProjectProductReadModel(Guid ProductId);

Message Flow End to End

Let’s walk through the full request flow.

1. Catalogs receives product creation request

Catalogs exposes an endpoint like:

1
POST /api/v1/catalogs/products

The request creates a product in the write model.

In the sample, that endpoint is implemented like this:

1
2
3
4
5
6
7
8
app.MapPost("/api/v1/catalogs/products", async (CreateProductRequestDto request, CatalogService service, CancellationToken cancellationToken) =>
{
    var result = await service.CreateProductAsync(
        new CreateProductRequest(request.Name, request.Price, request.Stock, request.CorrelationId),
        cancellationToken);

    return Results.Accepted($"/api/v1/catalogs/products/{result.ProductId}", result);
});

2. PostgreSQL write and Wolverine outbox share one transaction

Inside the Catalogs write flow, the real Wolverine version would open an EF Core transaction and enroll Wolverine durable messaging in the same DbContext.

Conceptually, the flow looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);

var product = Product.Create(request.Name, request.Price, request.Stock);
dbContext.Products.Add(product);

await messageBus.PublishAsync(new MessageEnvelope<ProductCreatedV1>(
    new ProductCreatedV1(product.Id, product.Name, product.Price)));

await messageBus.SendAsync(new ProjectProductReadModel(product.Id));

await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);

The important point is not the exact syntax. The important point is the behavior:

  • product write is persisted in PostgreSQL
  • outgoing RabbitMQ event is persisted durably
  • local projection command is persisted durably
  • commit happens once
  • Wolverine dispatches durable work only after commit succeeds

That is the outbox story.

In the local sample repository, the simplified service code focuses on the same business flow without wiring the full Wolverine infrastructure yet:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public async Task<CreateProductResult> CreateProductAsync(CreateProductRequest request, CancellationToken cancellationToken = default)
{
    var product = new Product
    {
        Id = Guid.NewGuid(),
        Name = request.Name,
        Price = request.Price,
        Stock = request.Stock
    };

    await _writeStore.AddAsync(product, cancellationToken);

    var integrationEvent = MessageEnvelope<ProductCreatedV1>.Create(
        new ProductCreatedV1(product.Id, product.Name, product.Price, product.Stock),
        request.CorrelationId);

    await ProjectReadModelAsync(new ProjectProductReadModel(product.Id), cancellationToken);

    return new CreateProductResult(product.Id, integrationEvent);
}

This sample code shows the same responsibilities clearly:

  • persist the write model
  • create the integration event contract
  • trigger internal projection work

External Integration Event with RabbitMQ

After commit, Wolverine publishes MessageEnvelope<ProductCreatedV1> to RabbitMQ.

Why wrap the event in a message envelope?

Because in real systems we usually need metadata such as:

  • message id
  • correlation id
  • causation id
  • message type
  • creation time
  • tracing or tenant information

A reusable MessageEnvelope<T> keeps transport-level and observability metadata consistent across services.

The sample envelope and event contracts look like this:

1
2
3
4
5
6
7
public sealed record MessageEnvelope<T>(
    Guid MessageId,
    DateTime CreatedAt,
    string CorrelationId,
    T Data);

public sealed record ProductCreatedV1(Guid ProductId, string Name, decimal Price, int Stock);

In this sample, Orders subscribes to the same event and imports the product into its own database.

That means Catalogs does not know anything about Orders internals. It only publishes an integration event.

Durable Inbox in Orders

On the consumer side, Orders uses Wolverine durable inbox semantics backed by PostgreSQL.

This solves a common problem in broker-based systems: duplicate delivery.

RabbitMQ can redeliver messages. Consumers can crash after partially processing a message. Network failures can make delivery uncertain. Without inbox protection, the same event may create duplicate rows or duplicate side effects.

With a durable inbox:

  • incoming messages are tracked durably
  • processing can be retried safely
  • duplicate deliveries can be ignored or handled idempotently
  • the consumer can recover after crashes without losing message state

In the sample, Orders consumes ProductCreatedV1 and writes an imported product record to its own PostgreSQL database. That is a classic microservice integration pattern: each service owns its own data and builds its own local view from events.

The sample consumer API and import service look like this:

1
2
3
4
5
app.MapPost("/api/v1/orders/products/import", async (MessageEnvelope<ProductCreatedV1> message, OrderImportService service, CancellationToken cancellationToken) =>
{
    var imported = await service.ImportAsync(message, cancellationToken);
    return imported ? Results.Accepted($"/api/v1/orders/products/{message.Data.ProductId}") : Results.Ok(new { Duplicate = true });
});
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public async Task<bool> ImportAsync(MessageEnvelope<ProductCreatedV1> message, CancellationToken cancellationToken = default)
{
    if (!_inboxStore.TryBegin(message.MessageId))
    {
        return false;
    }

    await _importStore.UpsertAsync(new ImportedProduct
    {
        Id = message.Data.ProductId,
        Name = message.Data.Name,
        Price = message.Data.Price,
        Stock = message.Data.Stock,
        SourceMessageId = message.MessageId
    }, cancellationToken);

    return true;
}

Internal Durable Command for MongoDB Projection

Now back to the internal processor scenario.

After Catalogs commits the product in PostgreSQL, it also dispatches a durable local command such as:

1
public record ProjectProductReadModel(Guid ProductId);

A local Wolverine handler processes that command and upserts a MongoDB read model.

Conceptually:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ProjectProductReadModelHandler
{
    public async Task Handle(
        ProjectProductReadModel command,
        CatalogDbContext dbContext,
        IMongoCollection<ProductReadModel> products,
        CancellationToken cancellationToken)
    {
        var product = await dbContext.Products
            .AsNoTracking()
            .FirstAsync(x => x.Id == command.ProductId, cancellationToken);

        var readModel = new ProductReadModel
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price,
            Stock = product.Stock
        };

        await products.ReplaceOneAsync(
            x => x.Id == readModel.Id,
            readModel,
            new ReplaceOptions { IsUpsert = true },
            cancellationToken);
    }
}

This handler runs outside the original HTTP request transaction.

That gives us several benefits:

  • MongoDB projection is retriable
  • request latency stays lower
  • PostgreSQL remains the transactional source of truth
  • projection failures do not corrupt the write transaction

This is a very useful pattern when one service needs to persist data in multiple stores but should only treat one of them as the primary transactional boundary.

The simplified sample implementation of that projection flow is:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public async Task ProjectReadModelAsync(ProjectProductReadModel command, CancellationToken cancellationToken = default)
{
    var product = await _writeStore.FindAsync(command.ProductId, cancellationToken)
        ?? throw new InvalidOperationException($"Product '{command.ProductId}' not found.");

    await _readStore.UpsertAsync(new ProductReadModel
    {
        Id = product.Id,
        Name = product.Name,
        Price = product.Price,
        Stock = product.Stock,
        SyncedAt = DateTime.UtcNow
    }, cancellationToken);
}

In a full Wolverine setup, this method would move behind a durable local handler so projection runs after commit with retries.

Testing the Flow

The sample also includes tests for both the end-to-end flow and duplicate protection.

Catalog flow test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[Fact]
public async Task create_product_should_persist_write_model_and_project_read_model()
{
    var flow = new SampleFlow();

    var result = await flow.RunAsync();

    Assert.True(result.Imported);
    Assert.NotNull(result.WriteModel);
    Assert.NotNull(result.ReadModel);
}

Idempotent import test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[Fact]
public async Task import_should_be_idempotent_for_duplicate_messages()
{
    var service = new OrderImportService(new OrderImportStore(), new InboxStore());
    var message = MessageEnvelope<ProductCreatedV1>.Create(new ProductCreatedV1(Guid.NewGuid(), "Mouse", 49.99m, 20));

    var first = await service.ImportAsync(message);
    var second = await service.ImportAsync(message);

    Assert.True(first);
    Assert.False(second);
}

These tests make the article more concrete because they verify the two most important outcomes:

  • the product is written and projected
  • duplicate messages are ignored safely

You can find the sample code in this repository:

Conclusion

Transactional messaging is really about failure handling, not only message publishing. When one request needs to update a write model, publish an integration event, and trigger internal asynchronous work, Wolverine gives a clean way to model those steps with outbox, inbox, and durable local processing.

In this sample, Catalogs owns the primary write transaction, Orders consumes integration events with duplicate protection, and the read-model projection is treated as reliable post-commit internal work. That separation is what keeps the system maintainable and resilient when failures happen between storage and messaging boundaries.

Reference

Built with Hugo
Theme Stack designed by Jimmy