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

Here is how the EF Core transactional outbox works under the hood. The product write and the outbox message commit atomically in one database transaction. The bus outbox then delivers the message to RabbitMQ or Kafka in the background, while MediatR dispatches the read-model projection directly:

flowchart TD classDef client fill:#a855f7,stroke:#7e22ce,color:#fff,font-size:14px classDef service fill:#06b6d4,stroke:#0891b2,color:#fff,font-size:14px classDef db fill:#f59e0b,stroke:#d97706,color:#fff,font-size:14px classDef broker fill:#10b981,stroke:#059669,color:#fff,font-size:14px classDef handler fill:#8b5cf6,stroke:#6d28d9,color:#fff,font-size:14px subgraph Client["šŸ–„ļø Client"] C1["HTTP Client"] end C1 --> EP1 subgraph Catalog["šŸ“¦ Catalog Service"] direction TB EP1["POST /products"] H1["CreateProductHandler"] EB1["IEventBus \nšŸ“” Publish"] DB1["PostgreSQL \nProducts + Outbox"] Outbox["šŸ“¤ Bus Outbox \nšŸ“¬ Drain & Deliver"] PH["ProjectReadModelHandler \nāš™ļø Project"] MDB["MongoDB \nReadModels"] EP1 --> H1 H1 --> EB1 H1 --> DB1 H1 --> PH DB1 -.-> Outbox PH --> MDB end Outbox --> B1 subgraph Broker["šŸ“Ø Message Broker"] B1{{"RabbitMQ / Kafka"}} end B1 --> CON1 subgraph Order["šŸ“¦ Order Service"] direction TB CON1["ProductCreatedConsumer \n🧩 IConsumer"] NH["NotificationHandler \n🧩 Handler"] DB2["PostgreSQL \nImportedProducts"] CON1 --> NH NH --> DB2 end class C1 client class EP1,CON1 service class H1,PH,NH handler class EB1 service class DB1,DB2,Outbox,MDB db class B1 broker

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. The endpoint delegates to a MediatR handler, which creates a Product entity and publishes MessageEnvelope<ProductCreatedV1> via IEventBus.
  3. SaveChangesAsync commits the product and the outbox message in one database transaction.
  4. The handler dispatches a ProjectProductReadModel command through MediatR, which projects the read model to MongoDB.
  5. The bus outbox delivers the integration event to RabbitMQ or Kafka in the background.
  6. Order consumes ProductCreatedV1 and idempotently imports the product into its own PostgreSQL store.

Here is the actual endpoint handler. It is deliberately thin — it just extracts the request payload and delegates to MediatR:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
internal static RouteHandlerBuilder MapCreateProductEndpoint(this IEndpointRouteBuilder endpoints)
    => endpoints.MapPost("/products", Handle).WithName("CreateProduct");

private static async Task<CreatedAtRoute<CreateProductResponse>> Handle(
    [FromBody] CreateProductRequest request,
    IMediator mediator, CancellationToken cancellationToken)
{
    var result = await mediator.Send(
        new CreateProductCommand(request.Name, request.Price, request.Stock), cancellationToken);
    return TypedResults.CreatedAtRoute(result, "CreateProduct", new { id = result.Id });
}

The handler runs the actual business logic — it creates the product, publishes the integration event through the EF Core outbox, dispatches the read-model projection, and returns the response:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
internal sealed class CreateProductHandler(CatalogsDbContext dbContext, IEventBus eventBus, IMediator mediator)
    : IRequestHandler<CreateProductCommand, CreateProductResponse>
{
    public async Task<CreateProductResponse> Handle(CreateProductCommand command, CancellationToken cancellationToken)
    {
        var product = Product.Create(command.Name, command.Price, command.Stock);
        dbContext.Products.Add(product);

        var integrationEvent = MessageEnvelope.Create(new ProductCreatedV1(
            product.Id, product.Name, product.Price, product.Stock, product.CreatedAtUtc));

        // Publish through MassTransit bus (goes through EF outbox)
        await eventBus.PublishAsync(integrationEvent, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);

        // Project read model via MediatR
        await mediator.Send(new ProjectProductReadModel(
            product.Id, product.Name, product.Price, product.Stock, product.CreatedAtUtc), cancellationToken);

        return new CreateProductResponse(product.Id, product.Name, product.Price, product.Stock);
    }
}

Why Local Processing Still Matters

External messaging is only part of the story. Internal work like read-model projection can be just as important.

In this sample, MongoDB projection is not an integration event for another service. It is internal application work that should run after the database commit but still in the same request flow.

Why?

  • MongoDB is a different persistence technology that should not block the database transaction.
  • PostgreSQL should stay the source of truth for the write model.
  • The projection should happen after the commit so the write model is guaranteed to exist.

So the sample triggers projection as a MediatR command dispatched after SaveChangesAsync. The ProjectProductReadModel command flows through the same MediatR pipeline, just like any other request.

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/ECommerce.Services.Catalogs: write-side product logic and internal read-model projection flow
  • src/Services/Catalog/ECommerce.Services.Catalogs.Api: HTTP endpoints for creating products and reading write/read models
  • src/Services/Order/ECommerce.Services.Orders: downstream import logic with inbox-style duplicate protection
  • src/Services/Order/ECommerce.Services.Orders.Api: HTTP endpoints for importing and querying products in the order service
  • src/Services/Shared/ECommerce.Services.Shared/Contracts: MessageEnvelope<T>, ProductCreatedV1, and ProjectProductReadModel
  • src/BuildingBlocks/BuildingBlocks.Integration.MassTransit: MassTransit wiring, outbox, MediatR-backed internal command bus
  • tests/Shared/Tests.Shared: shared test infrastructure (TestContainers for Postgres, MongoDB, RabbitMQ, Kafka)
  • tests/Services/Catalog/ECommerce.Services.Catalogs.IntegrationTests: integration test for create + project + outbox drain
  • tests/Services/Order/ECommerce.Services.Orders.IntegrationTests: integration tests for consumer error and dead-letter handling

The shared contracts are intentionally small and explicit. The envelope wraps every message with idempotency metadata, and the event and internal command carry the full product state needed by downstream consumers and the read-model projector:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public sealed record MessageEnvelope<TMessage>(
    Guid MessageId, Guid CorrelationId, DateTime OccurredAtUtc, TMessage Message
) : IMessageEnvelopeMetadata where TMessage : IMessage
{
    Type IMessageEnvelopeMetadata.MessageType => typeof(TMessage);
}

public static class MessageEnvelope
{
    public static MessageEnvelope<TMessage> Create<TMessage>(TMessage message,
        Guid? correlationId = null, Guid? messageId = null, DateTime? occurredAtUtc = null)
        where TMessage : IMessage => new(
            messageId ?? Guid.NewGuid(),
            correlationId ?? Guid.NewGuid(),
            occurredAtUtc ?? DateTime.UtcNow,
            message);
}
1
2
3
4
// Integration event — published to the broker for downstream services
public sealed record ProductCreatedV1(
    Guid ProductId, string Name, decimal Price, int Stock, DateTime CreatedAtUtc
) : IIntegrationEvent;
1
2
3
4
// Internal command — dispatched through MediatR, never leaves the service boundary
public sealed record ProjectProductReadModel(
    Guid ProductId, string Name, decimal Price, int Stock, DateTime CreatedAtUtc
) : IInternalCommand;

The Order consumer uses an upsert pattern for idempotency — if the product already exists in its local PostgreSQL, it updates rather than inserting a duplicate.

Here is the actual ProductCreatedConsumer implementation that powers this flow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public sealed class ProductCreatedConsumer(OrdersDbContext dbContext)
    : IConsumer<MessageEnvelope<ProductCreatedV1>>
{
    public async Task Consume(ConsumeContext<MessageEnvelope<ProductCreatedV1>> context)
    {
        var message = context.Message.Message;
        if (string.Equals(message.Name, "faulty-product-created", StringComparison.OrdinalIgnoreCase))
            throw new InvalidOperationException(
                "Intentional consumer failure for retry and dead-letter tests.");

        var existing = await dbContext.ImportedProducts.SingleOrDefaultAsync(
            x => x.Id == message.ProductId, context.CancellationToken);

        if (existing is null)
            dbContext.ImportedProducts.Add(ImportedProduct.Create(
                message.ProductId, message.Name, message.Price, message.Stock, message.CreatedAtUtc));
        else
            existing.Update(message.Name, message.Price, message.Stock, message.CreatedAtUtc);

        await dbContext.SaveChangesAsync(context.CancellationToken);
    }
}

When the consumer throws for faulty-product-created, MassTransit moves the message to the error queue (RabbitMQ DLX) or the dead-letter topic (Kafka), and the integration tests verify no product is persisted in the database.

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.

The key point is that the application code never references RabbitMQ or Kafka types directly — it uses IEventBus and IConsumer<T>, and MassTransit maps them to the configured transport at startup.

Integration Tests

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

The Catalog integration test verifies the full flow end to end with live containers (PostgreSQL, MongoDB, RabbitMQ):

 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
28
29
[Fact]
public async Task PostProduct_ShouldCreateWriteAndReadModels_AndPublishEvent()
{
    using var client = Factory.CreateClient();
    var request = new { name = "Test Basket", price = 15.25m, stock = 10 };

    // Act
    var response = await client.PostAsJsonAsync(
        "/api/v1/catalogs/products", request, cancellationToken);

    // Assert – HTTP
    Assert.Equal(HttpStatusCode.Created, response.StatusCode);

    // Assert – write model persisted in PostgreSQL
    await ExecuteCatalogsDbContextAsync(async dbContext =>
    {
        var entity = await dbContext.Products.FindAsync(created!.Id);
        Assert.NotNull(entity);
        Assert.Equal(request.name, entity!.Name);
    });

    // Assert – bus outbox drained (MassTransit delivered the event)
    Assert.True(await WaitForOutboxToDrainAsync(TimeSpan.FromSeconds(30)));

    // Assert – read model projected to MongoDB via MediatR
    var readModel = await WaitForMongoReadModelAsync(created!.Id);
    Assert.NotNull(readModel);
    Assert.Equal(request.name, readModel!.Name);
}

The Order integration tests verify fault handling for each transport. Here is the RabbitMQ error-queue test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[Fact]
public async Task FaultyRabbitMqMessage_ShouldBeMovedToErrorQueue_WhenConsumerFails()
{
    var envelope = MessageEnvelope.Create(
        new ProductCreatedV1(
            Guid.NewGuid(), "faulty-product-created", 9.99m, 1, DateTime.UtcNow));

    var exception = await Assert.ThrowsAsync<InvalidOperationException>(
        () => InvokeProductCreatedConsumerAsync(envelope, cancellationToken));

    Assert.Equal(
        "Intentional consumer failure for retry and dead-letter tests.",
        exception.Message);
}

The Order tests also include an equivalent Kafka error-queue test and a verification that faulty messages do not persist any imported product.

There are also unit tests that cover individual behavior without infrastructure. Instead of testing consumers directly, the tests now test the MediatR handlers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Catalog — verifies handler calls UpsertAsync once
[Fact]
public async Task Handle_ShouldUpsertReadModel()
{
    var repository = new Mock<IProductReadRepository>();
    var handler = new ProjectProductReadModelHandler(repository.Object);

    await handler.Handle(command, CancellationToken.None);

    repository.Verify(x => x.UpsertAsync(
        It.Is<ProductReadModel>(m => m.Id == command.ProductId),
        CancellationToken.None), Times.Once);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Order — verifies idempotent import behavior via notification handler
[Fact]
public async Task Handle_ShouldUpdate_ImportedProduct_WhenAlreadyExists()
{
    dbContext.ImportedProducts.Add(ImportedProduct.Create(productId, "Old Name", 10m, 5, oldDate));
    await dbContext.SaveChangesAsync();

    var handler = new ProductCreatedNotificationHandler(dbContext);
    await handler.Handle(notification, CancellationToken.None);

    var imported = await dbContext.ImportedProducts.SingleAsync();
    Assert.Equal("Starter Basket", imported.Name);
    Assert.Equal(42.50m, imported.Price);
}

The tests use TestContainers to spin up real PostgreSQL, MongoDB, RabbitMQ, and Kafka containers per test collection. The integration tests prove the full flow: HTTP → EF write → outbox drain → MediatR projection → MongoDB. The unit tests prove handler behavior and idempotency without infrastructure.

Running the Sample

Build the solution:

1
dotnet build src/masstransit-transactional-messaging-sample/masstransit-transactional-messaging-sample.slnx

Run unit tests (fast, no infrastructure needed):

1
2
dotnet test --project src/masstransit-transactional-messaging-sample/tests/Services/Catalog/ECommerce.Services.Catalogs.UnitTests
dotnet test --project src/masstransit-transactional-messaging-sample/tests/Services/Order/ECommerce.Services.Orders.UnitTests

Run integration tests (requires Docker — starts PostgreSQL, MongoDB, RabbitMQ, and Kafka containers):

1
2
dotnet test --project src/masstransit-transactional-messaging-sample/tests/Services/Catalog/ECommerce.Services.Catalogs.IntegrationTests
dotnet test --project src/masstransit-transactional-messaging-sample/tests/Services/Order/ECommerce.Services.Orders.IntegrationTests

Conclusion

Transactional messaging is not about the broker. Whether RabbitMQ or Kafka, the hard problems are the same: keeping writes and message delivery consistent, protecting consumers from duplicates, and handling local side effects without coupling them to the request transaction.

The key is designing the workflow so each piece stays consistent when things fail — outbox in the same DB transaction, idempotent consumers, and a MediatR handler for local work. The sample proves this end to end with integration tests against live containers.

You can find the sample code in this repository:

Reference

Built with Hugo
Theme Stack designed by Jimmy