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:
- 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.
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.Catalogstores the product in PostgreSQL.CatalogcreatesMessageEnvelope<ProductCreatedV1>for the configured broker.Catalogtriggers internal projection work for MongoDB.OrderconsumesProductCreatedV1and imports the product into its own PostgreSQL store.- Duplicate deliveries are ignored through inbox-style protection.
Here is the sample API entry point that starts the flow:
|
|
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 forcatalogs-api,orders-api, PostgreSQL, MongoDB, and either RabbitMQ or Kafka depending on configurationsrc/Services/Catalog/Catalog: write-side product logic and internal read-model projection flowsrc/Services/Catalog/Catalog.Api: HTTP endpoints for creating products and reading write/read modelssrc/Services/Order/Order: downstream import logic with inbox-style duplicate protectionsrc/Services/Order/Order.Api: HTTP endpoints for importing and querying products in the order servicesrc/Shared/Contracts:MessageEnvelope<T>,ProductCreatedV1, andProjectProductReadModeltests/Shared/Tests.Shared: shared end-to-end flow helper used by service teststests/Services/Catalog/Catalog.Tests: integration test for create + project flowtests/Services/Order/Order.Tests: integration test for idempotent import behavior
The shared contracts are intentionally small and explicit:
|
|
Message Flow End to End
Let’s walk through the full request flow.
1. Catalog receives product creation request
Catalog exposes an endpoint like:
|
|
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:
|
|
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:
|
|
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:
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.
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 theOrderconsumer can handle it through a real in-memory MassTransit bus-backed test setup
|
|
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
|
|
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:
|
|
Run the tests:
|
|
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.