MassTransit Outbox Pattern with EF Core and MongoDB in .NET
Learn the transactional outbox pattern in .NET using MassTransit with EF Core and MongoDB so your database and message broker never fall out of sync.
MassTransit Outbox Pattern with EF Core and MongoDB
Imagine you run a small sweet shop. A customer pays for a box of laddoos. You write the sale in your notebook, and then you shout to the kitchen, "One box of laddoos, please!" Now think about what happens if you write the sale down, but a loud truck passes and the kitchen never hears you. Money taken, but no sweets made. The customer is upset.
This is exactly the problem we face in software. We save data to a database, and then we tell another system (a message broker) "hey, something happened!" But these are two separate steps. If the first step works and the second fails, our data and our messages no longer agree. The outbox pattern is the simple, clever trick that fixes this. And MassTransit gives us this trick almost for free.
In this guide we will learn the outbox pattern slowly, with pictures, and then build it two ways: once with EF Core (for SQL databases) and once with MongoDB.
The problem in plain words
When a web app handles a request, it often does two things:
- It saves a change to the database (for example, "Order 123 is now placed").
- It publishes an event so other services know about it (for example,
OrderPlaced).
These two things talk to two different systems. The database is one box. The message broker (like RabbitMQ or Azure Service Bus) is another box. There is no magic wire that keeps them in step.
Look at what can go wrong.
The order is in the database. But the OrderPlaced event never reached the broker. Now the warehouse service never ships the order. The customer waits forever. This is called a dual-write problem: two writes to two systems that should be one, but are not.
You might think, "Let me publish first, then save." That just flips the bug. Now you might tell the warehouse to ship an order that the database never actually saved. Either order of steps can break.
The everyday fix: write it in one place first
Back to the sweet shop. A smarter shopkeeper does this: instead of shouting to the kitchen, she writes every kitchen order on a small slip and drops it in a tray right next to the sales notebook. Writing the sale and dropping the slip happen together, in one motion. A helper boy then keeps checking the tray and carries each slip to the kitchen.
If the shop loses power for a second, no problem. The slip is still in the tray. When power comes back, the helper picks it up and the kitchen still gets the order. Nothing is lost.
That tray is the outbox.
The outbox idea
Steps
Save data + message
Both in one transaction
Outbox table
Message waits safely here
Background relay
Reads waiting messages
Message broker
Finally delivered
How the outbox pattern really works
The key insight is this: instead of sending the message to the broker right away, we save the message into the same database, in the same transaction as our business data. A transaction is an all-or-nothing deal. Either both the order and the message are saved, or neither is.
Then a separate background worker wakes up every so often, looks in the outbox, and delivers any messages it finds to the broker. After it delivers a message, it marks that message as "done" so it is not sent twice.
Because the save is atomic, we can never end up with an order but no message. And because the relay runs on its own, a broker outage no longer loses data. The messages simply wait in the outbox until the broker is back.
| Step | Where it happens | What can go wrong | Outbox protection |
|---|---|---|---|
| Save order | Database transaction | Crash mid-save | Transaction rolls back fully |
| Save message | Same transaction | Crash mid-save | Rolls back with the order |
| Deliver message | Background relay | Broker is down | Message stays, retried later |
| Mark delivered | Database update | Crash after send | Duplicate detection handles it |
Where MassTransit fits in
You could build all of this by hand. You would create an outbox table, write the relay loop, handle retries, handle duplicates, and so on. It is a lot of careful work.
MassTransit is a popular .NET messaging library that does all of this for you. You write normal code that calls Publish and SaveChanges, and MassTransit quietly stores the message in the outbox during your transaction, then runs its own relay in the background.
A quick honest note on licensing. MassTransit v8 is open source under the Apache 2.0 license and will get security and critical fixes through at least the end of 2026. MassTransit v9 moves to a commercial license. Small organizations (under one million dollars in yearly revenue) may use a free tier, while larger ones pay a monthly fee. This is the same direction MediatR and AutoMapper took. For learning and for many small projects, v8 is still a fine choice. Plan your version on purpose.
What MassTransit automates for you
Steps
Your code calls Publish
Looks like a normal send
Outbox interceptor
Catches the message
Stored in DB
Inside your transaction
Delivery service
Background, automatic
Broker
Reliable delivery
MassTransit actually has two related outbox pieces:
- The bus outbox captures messages your endpoint publishes and stores them. You turn it on with
UseBusOutbox(). - The consumer outbox (also called inbox/outbox) is used inside consumers so that handling an incoming message and publishing new messages is also atomic.
For this guide we focus on the bus outbox, which is the part most people mean when they say "the outbox pattern."
Building it with EF Core
Let us start with EF Core, which works against SQL Server, PostgreSQL, or MySQL. First, install the packages your project needs (the core MassTransit package plus the EF Core integration). Then register everything in your Program.cs.
builder.Services.AddDbContext<OrderDbContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.AddMassTransit(x =>
{
// Register the outbox tables against our DbContext
x.AddEntityFrameworkOutbox<OrderDbContext>(o =>
{
// Tell MassTransit which database lock to use
o.UsePostgres();
// How often the relay checks for waiting messages
o.QueryDelay = TimeSpan.FromSeconds(1);
// Turn on the bus outbox so Publish goes through the outbox
o.UseBusOutbox();
});
x.AddConsumer<OrderPlacedConsumer>();
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("localhost", "/", h => { });
cfg.ConfigureEndpoints(context);
});
});Next, your DbContext needs the outbox tables. MassTransit gives you handy extension methods so you do not have to write the table schemas yourself.
public class OrderDbContext : DbContext
{
public OrderDbContext(DbContextOptions<OrderDbContext> options)
: base(options) { }
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// These three lines add the outbox + inbox tables
modelBuilder.AddInboxStateEntity();
modelBuilder.AddOutboxMessageEntity();
modelBuilder.AddOutboxStateEntity();
}
}After this you create and apply an EF Core migration so the outbox tables exist in your database. Now the magic part. In your business logic, you do not send to the broker directly. You publish and save in the same transaction.
public class PlaceOrderHandler
{
private readonly OrderDbContext _db;
private readonly IPublishEndpoint _publishEndpoint;
public PlaceOrderHandler(OrderDbContext db, IPublishEndpoint publishEndpoint)
{
_db = db;
_publishEndpoint = publishEndpoint;
}
public async Task Handle(PlaceOrderCommand command, CancellationToken ct)
{
var order = new Order { Id = command.OrderId, Total = command.Total };
_db.Orders.Add(order);
// This does NOT hit the broker yet. It is stored in the outbox.
await _publishEndpoint.Publish(new OrderPlaced(order.Id), ct);
// The order AND the outbox message are saved together, atomically.
await _db.SaveChangesAsync(ct);
}
}Notice how clean this is. You call Publish, then SaveChangesAsync. MassTransit's interceptor turns that Publish into a row in the outbox table instead of a network call. When SaveChangesAsync runs, both the order and the message land together. The background delivery service does the rest.
Building it with MongoDB
MongoDB needs a small but important extra step. To save two documents atomically (your business document and the outbox message), MongoDB needs multi-document transactions. And those only work when MongoDB runs as a replica set, not as a single standalone server. So before anything else: run MongoDB as a replica set, even a single-node one for local development.
With that in place, the MassTransit setup is very similar in spirit.
builder.Services.AddMassTransit(x =>
{
x.AddMongoDbOutbox(o =>
{
// How often the relay checks for messages
o.QueryDelay = TimeSpan.FromSeconds(1);
// Point it at your Mongo client and database
o.ClientFactory(provider =>
new MongoClient("mongodb://localhost:27017/?replicaSet=rs0"));
o.DatabaseFactory(provider =>
provider.GetRequiredService<IMongoClient>().GetDatabase("orders"));
// Ignore duplicate messages seen within this window
o.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
// Turn on the bus outbox
o.UseBusOutbox();
});
x.AddConsumer<OrderPlacedConsumer>();
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("localhost", "/", h => { });
cfg.ConfigureEndpoints(context);
});
});One useful detail: if a consumer service only reads messages and does not itself publish through the outbox, you can leave out AddMongoDbOutbox on that service. In that case it does not need a replica set. The replica set requirement comes from the atomic multi-document save, which only the publishing side performs.
| Concern | EF Core outbox | MongoDB outbox |
|---|---|---|
| Setup method | AddEntityFrameworkOutbox | AddMongoDbOutbox |
| Database lock | UsePostgres, UseSqlServer, UseMySql | Handled by Mongo session |
| Special requirement | A migration for outbox tables | A replica set for transactions |
| Atomic save | SQL transaction | Multi-document transaction |
| Duplicate handling | Inbox state table | DuplicateDetectionWindow |
The flow for MongoDB looks almost the same as EF Core. The difference is the engine underneath: a Mongo transaction instead of a SQL transaction.
Things to watch out for
The outbox pattern is friendly, but a few details trip people up. Keep these in mind.
- At-least-once, not exactly-once. The relay might deliver a message, crash before marking it done, and deliver it again on restart. So your consumers must be idempotent — safe to receive the same message twice. Use a message id and skip messages you have already handled.
- A little delay. The relay checks on a timer (
QueryDelay). So messages are not instant. A delay of one second is usually fine, but it is not zero. Do not use the outbox where you need millisecond delivery. - Replica set for MongoDB. This is the most common mistake. A standalone MongoDB cannot do transactions, so the atomic save silently fails to work as expected. Always run a replica set on the publishing side.
- Ordering. Messages generally go out in the order they were saved, but across many instances do not assume strict global ordering. Design for it.
- Clean up old rows. Delivered outbox rows can pile up. MassTransit cleans up after delivery, but keep an eye on table or collection growth in busy systems.
Putting it all together
Here is the whole journey in one picture, from a customer click to a happy warehouse.
The beautiful part is that your application code stays simple. You write Publish then SaveChanges, exactly as you would naturally. MassTransit and the outbox quietly turn those two ordinary calls into a rock-solid, crash-proof pipeline. Your database and your broker never disagree again. The sweet shop never loses an order, even when the power flickers.
Quick recap
- The dual-write problem is when you save to a database and send to a broker as two separate steps, and a crash between them leaves the two systems out of sync.
- The outbox pattern fixes this by saving the message into the same database transaction as your business data, then delivering it later with a background relay.
- This makes the save atomic: you can never have an order without its message, or a message without its order.
- MassTransit automates the whole thing. You call
PublishthenSaveChanges, and it stores the message in the outbox and delivers it for you. - With EF Core, use
AddEntityFrameworkOutbox, add the outbox entities to yourDbContext, and run a migration. - With MongoDB, use
AddMongoDbOutbox, and remember MongoDB must run as a replica set so it can do multi-document transactions. - Delivery is at-least-once, so make consumers idempotent and accept a small delivery delay.
- MassTransit v8 is open source through at least end of 2026; v9 is commercial with a free tier for small organizations. Choose your version on purpose.
References and further reading
- MassTransit Transactional Outbox Pattern (official docs)
- MassTransit Outbox Configuration (official docs)
- Announcing MassTransit v9 and commercial licensing
- Anton Martyniuk: Use MassTransit To Implement Outbox Pattern with EF Core and MongoDB
- Milan Jovanović: MediatR and MassTransit Going Commercial — What This Means For You
Related Patterns
Scaling the Outbox Pattern in .NET: From Hundreds to Billions of Messages
Scale the Outbox Pattern in .NET to billions of messages a day with batching, indexes, SKIP LOCKED, and parallel workers — explained simply with diagrams.
Using MassTransit with RabbitMQ and Azure Service Bus in .NET
Learn how MassTransit lets one set of .NET code run on both RabbitMQ and Azure Service Bus, with simple consumers, publishers, and config examples.
Implementing the Saga Pattern with MassTransit in .NET
Learn the Saga pattern in .NET with MassTransit state machines — states, events, correlation, persistence, retries, and compensation, explained in simple, friendly steps.
MassTransit with RabbitMQ and Azure Service Bus: Is It Worth a Commercial License?
MassTransit went commercial in v9. See how it works with RabbitMQ and Azure Service Bus, what the new license costs, and whether it is worth paying for.
The Outbox Pattern in .NET: Never Lose a Message Again
Learn the Outbox Pattern in .NET with simple, real-life examples. Save your data and your messages in one transaction so a broker outage can never lose an event. Includes EF Core code, diagrams, and best practices.
Request-Response Messaging Pattern With MassTransit in .NET
Learn the request-response messaging pattern with MassTransit in .NET using IRequestClient, timeouts, and multiple response types with simple examples.