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:
- Store the product in PostgreSQL.
- Publish
ProductCreatedV1soOrdercan import that product through RabbitMQ or Kafka. - 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:
Sample Scenario
The sample uses two microservices in an e-commerce domain:
Catalog: write-side service for creating productsOrder: downstream consumer that imports product data from integration events
The flow is:
CatalogreceivesPOST /api/v1/catalogs/products.- The endpoint delegates to a MediatR handler, which creates a
Productentity and publishesMessageEnvelope<ProductCreatedV1>viaIEventBus. SaveChangesAsynccommits the product and the outbox message in one database transaction.- The handler dispatches a
ProjectProductReadModelcommand through MediatR, which projects the read model to MongoDB. - The bus outbox delivers the integration event to RabbitMQ or Kafka in the background.
OrderconsumesProductCreatedV1and 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:
|
|
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:
|
|
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 forcatalogs-api,orders-api, PostgreSQL, MongoDB, and either RabbitMQ or Kafka depending on configurationsrc/Services/Catalog/ECommerce.Services.Catalogs: write-side product logic and internal read-model projection flowsrc/Services/Catalog/ECommerce.Services.Catalogs.Api: HTTP endpoints for creating products and reading write/read modelssrc/Services/Order/ECommerce.Services.Orders: downstream import logic with inbox-style duplicate protectionsrc/Services/Order/ECommerce.Services.Orders.Api: HTTP endpoints for importing and querying products in the order servicesrc/Services/Shared/ECommerce.Services.Shared/Contracts:MessageEnvelope<T>,ProductCreatedV1, andProjectProductReadModelsrc/BuildingBlocks/BuildingBlocks.Integration.MassTransit: MassTransit wiring, outbox, MediatR-backed internal command bustests/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 draintests/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:
|
|
|
|
|
|
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:
|
|
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:
rabbitmquses MassTransit RabbitMQ transport in both APIskafkauses a MassTransit Kafka rider with a producer inCatalogand a topic endpoint inOrder
If you run the APIs directly, set this in both API projects:
|
|
Or switch the configuration value to 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):
|
|
The Order integration tests verify fault handling for each transport. Here is the RabbitMQ error-queue test:
|
|
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:
|
|
|
|
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:
|
|
Run unit tests (fast, no infrastructure needed):
|
|
Run integration tests (requires Docker ā starts PostgreSQL, MongoDB, RabbitMQ, and Kafka containers):
|
|
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
- https://masstransit.io/documentation/concepts/transports
- https://masstransit.io/documentation/concepts/consumers
- https://masstransit.io/documentation/configuration/transports/rabbitmq
- https://masstransit.io/documentation/configuration/transports/kafka
- https://masstransit.io/documentation/patterns/transactional-outbox