Skip to main content
SEMastery

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.

13 min readUpdated February 24, 2026

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:

  1. It saves a change to the database (for example, "Order 123 is now placed").
  2. 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.

Without the outbox, a crash between two steps leaves data and messages out of sync

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

Save data + message
Outbox table
Background relay
Message broker

Steps

1

Save data + message

Both in one transaction

2

Outbox table

Message waits safely here

3

Background relay

Reads waiting messages

4

Message broker

Finally delivered

Save the business data and the message together, then deliver the message later

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.

The two halves of the outbox: an atomic save, then a separate delivery loop

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.

StepWhere it happensWhat can go wrongOutbox protection
Save orderDatabase transactionCrash mid-saveTransaction rolls back fully
Save messageSame transactionCrash mid-saveRolls back with the order
Deliver messageBackground relayBroker is downMessage stays, retried later
Mark deliveredDatabase updateCrash after sendDuplicate 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

Your code calls Publish
Outbox interceptor
Stored in DB
Delivery service
Broker

Steps

1

Your code calls Publish

Looks like a normal send

2

Outbox interceptor

Catches the message

3

Stored in DB

Inside your transaction

4

Delivery service

Background, automatic

5

Broker

Reliable delivery

You write simple Publish and Save calls; MassTransit handles the rest

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.

EF Core flow: publish is captured, saved with the order, then relayed

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.

ConcernEF Core outboxMongoDB outbox
Setup methodAddEntityFrameworkOutboxAddMongoDbOutbox
Database lockUsePostgres, UseSqlServer, UseMySqlHandled by Mongo session
Special requirementA migration for outbox tablesA replica set for transactions
Atomic saveSQL transactionMulti-document transaction
Duplicate handlingInbox state tableDuplicateDetectionWindow

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.

MongoDB flow uses a replica-set transaction to save business data and the outbox message together

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.

End-to-end: an order is saved with its message atomically, then relayed and consumed reliably

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 Publish then SaveChanges, and it stores the message in the outbox and delivers it for you.
  • With EF Core, use AddEntityFrameworkOutbox, add the outbox entities to your DbContext, 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

Related Patterns