Featured image of post MassTransit: Transactional Messaging with RabbitMQ or Kafka, Inbox, Outbox, and Reliable Local Processing

MassTransit: Transactional Messaging with RabbitMQ or Kafka, Inbox, Outbox, and Reliable Local Processing

Learn how to use MassTransit with RabbitMQ or Kafka in a two-microservice e-commerce sample and keep database writes, integration events, inbox handling, and local post-commit processing aligned through transactional messaging patterns.

When we build microservices, publishing a message is usually the easy part. The hard part is making sure database writes, integration events, and follow-up processing stay consistent when failures happen between those steps.

In this post, I want to show how to model that problem with MassTransit in a practical e-commerce sample. The sample uses two microservices, Catalog and Order, and keeps the same scenario as my Wolverine article so the comparison stays concrete. It demonstrates four things together:

  • transactional outbox style publishing after the database commit
  • durable inbox style duplicate protection on the consumer side
  • reliable local post-commit processing for internal work
  • transport switching between RabbitMQ and Kafka through configuration, with both API paths wired explicitly in MassTransit

The local sample keeps the same boundaries as the Wolverine version: shared contracts, separate service projects, Aspire orchestration, and focused integration tests. That makes it easier to compare framework behavior without changing the business scenario.

The Problem We Want to Solve

Assume Catalog receives a request to create a product.

That request needs to do three things:

  1. Store the product in PostgreSQL.
  2. Publish ProductCreatedV1 so Order can import that product through RabbitMQ or Kafka.
  3. Update a MongoDB read model for query scenarios.

These steps do not belong to one physical transaction:

  • PostgreSQL write uses one database transaction.
  • RabbitMQ or Kafka publish is external broker communication.
  • MongoDB projection belongs to a different persistence boundary.

If we do these steps manually in sequence, failures create inconsistent state 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 where transactional messaging patterns matter more than the broker itself.

What MassTransit Gives Us

MassTransit is a distributed application framework for .NET that gives us a clean abstraction over brokers like RabbitMQ and Kafka, plus middleware and patterns for reliable messaging.

For this scenario, the important capabilities are:

  • publish and send abstractions over multiple transports
  • consumer pipelines that support idempotent handling strategies
  • outbox-oriented patterns to avoid publish-before-commit problems
  • endpoint configuration that can stay stable while the transport changes

MassTransit does not magically create one distributed transaction across PostgreSQL, MongoDB, and the broker. Instead, it helps us structure the application so writes happen first, messages are emitted in a reliable way, and consumers can safely handle duplicates.

Sample Scenario

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

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

The flow is:

  1. Catalog receives POST /api/v1/catalogs/products.
  2. Catalog stores the product in PostgreSQL.
  3. Catalog creates MessageEnvelope<ProductCreatedV1> for the configured broker.
  4. Catalog triggers internal projection work for MongoDB.
  5. Order consumes ProductCreatedV1 and imports the product into its own PostgreSQL store.
  6. Duplicate deliveries are ignored through inbox-style protection.

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 Reliable Local Processing Still Matters

External messaging is only part of the story. Internal asynchronous work can be just as important.

In this sample, MongoDB projection is not an integration event for another service. It is internal application work, but it still should not run inline with the 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.
  • PostgreSQL should stay the source of truth for the write model.
  • We still want reliable eventual consistency for the read model.

So the sample keeps projection as separate post-write work. Conceptually, this is the same architectural need whether you use Wolverine, MassTransit, or another messaging stack.

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, PostgreSQL, MongoDB, and either RabbitMQ or Kafka depending on configuration
  • 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

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. Catalog receives product creation request

Catalog exposes an endpoint like:

1
POST /api/v1/catalogs/products

The request creates a product in the write model.

2. Database write happens before integration delivery

Inside the Catalog write flow, the important idea is to persist the write model first, then emit integration work in a way that does not break consistency.

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 dbContext.SaveChangesAsync(cancellationToken);

await publishEndpoint.Publish(MessageEnvelope<ProductCreatedV1>.Create(
    new ProductCreatedV1(product.Id, product.Name, product.Price, product.Stock),
    request.CorrelationId), cancellationToken);

await transaction.CommitAsync(cancellationToken);

The exact MassTransit wiring can vary depending on whether you use an outbox implementation, EF Core integration, or a more explicit application service boundary. The important point is the behavior we want:

  • product write is persisted in PostgreSQL
  • integration event is emitted only after the write is considered successful
  • local projection work is separated from the request transaction
  • downstream consumers can handle retries and duplicates safely

In the local sample repository, the simplified service code focuses on the same business flow:

 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 keeps the responsibilities explicit:

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

RabbitMQ or Kafka with the Same Application Flow

One useful part of MassTransit is that the application flow can stay stable while the transport changes.

In this sample, both APIs default to rabbitmq, and the APIs normalize and validate the configured transport in startup.

The current wiring is:

  • rabbitmq uses MassTransit RabbitMQ transport in both APIs
  • kafka uses a MassTransit Kafka rider with a producer in Catalog and a topic endpoint in Order

If you run the APIs directly, set this in both API projects:

1
2
3
4
5
{
    "Messaging": {
        "Transport": "rabbitmq"
    }
}

Or switch the configuration value to Kafka:

1
2
3
4
5
{
    "Messaging": {
        "Transport": "kafka"
    }
}

The Aspire AppHost reads the same setting and starts the matching broker container. In the application layer, MassTransit is configured from the same transport value so the switch stays centralized between RabbitMQ and Kafka.

Integration Tests

Like my other messaging samples, this one includes focused integration tests instead of only showing configuration snippets.

The Catalog integration tests verify two things for both supported transport values:

  • the create flow persists the write model and read model
  • the service publishes MessageEnvelope<ProductCreatedV1> and the Order consumer can handle it through a real in-memory MassTransit bus-backed test setup
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[Theory]
[MemberData(nameof(SupportedTransports))]
public async Task create_product_should_persist_write_model_and_project_read_model_for_supported_brokers(string transport)
{
    var flow = new SampleFlow();

    var result = await flow.RunAsync(transport);

    Assert.Equal(transport, result.Transport);
    Assert.True(result.Imported);
    Assert.NotNull(result.WriteModel);
    Assert.NotNull(result.ReadModel);
    Assert.NotNull(result.OrderProduct);
}

The Order integration tests verify two things for both supported transport values:

  • idempotent import behavior for duplicate messages
  • consumer handling of a published integration event through a real in-memory MassTransit bus-backed test setup
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
[Theory]
[MemberData(nameof(SupportedTransports))]
public async Task import_should_be_idempotent_for_duplicate_messages_for_supported_brokers(string transport)
{
    var service = new OrderImportService(new OrderImportStore(), new InboxStore());
    var message = MessageEnvelope<ProductCreatedV1>.Create(
        new ProductCreatedV1(Guid.NewGuid(), "Mouse", 49.99m, 20),
        $"order-import-{transport}");

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

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

There are also transport configuration tests that explicitly validate supported values, normalization, and rejection of unsupported transports. These tests matter because transactional messaging is mostly about failure behavior and duplicate handling, not only about whether a publish call compiles.

One important detail about the current test strategy: the tests prove the application flow, duplicate handling, and MassTransit publish/consume behavior with an in-memory bus. The API projects themselves are now wired for both live RabbitMQ and live Kafka, but the automated tests do not spin up those external brokers.

Running the Sample

Build the sample:

1
dotnet build blog-samples/src/masstransit-transactional-messaging-sample/masstransit-transactional-messaging-sample.sln

Run the tests:

1
2
dotnet test blog-samples/src/masstransit-transactional-messaging-sample/tests/Services/Catalog/Catalog.Tests/Catalog.Tests.csproj
dotnet test blog-samples/src/masstransit-transactional-messaging-sample/tests/Services/Order/Order.Tests/Order.Tests.csproj

You can find the sample code in this repository:

Conclusion

MassTransit helps a lot with transport abstraction and reliable message-driven application structure, but the real value comes from how we design the workflow around it. We still need to separate write boundaries, publish integration events safely, protect consumers from duplicates, and move secondary work like projections out of the request transaction.

That is the main point of this sample. It keeps the same e-commerce scenario as the Wolverine article, but shows how to think about the same transactional messaging problem with MassTransit and with integration tests that verify the important behavior.

Built with Hugo
Theme Stack designed by Jimmy