Levelwise
English
Distributed systems

The Outbox and Inbox patterns

Changing the database and sending a message are two separate jobs, and one may happen without the other. With Outbox, save the message together with the data in one transaction and send it later. With Inbox, save the IDs of received messages so a duplicate message does not run twice.

Not reviewedWritten with AI helpReading time: 15 minExample of placing an order in an online shopC# and EF Core code on .NET 10

Author: bezzad

The problem: two writes, no shared transaction

A customer places an order. The order service does two things:

  1. It saves the order in its own database.
  2. It sends the message “order placed” (OrderPlaced) to Kafka or RabbitMQ. The inventory service uses this message to reserve the items.

The simple code looks like this:

db.Orders.Add(order);
await db.SaveChangesAsync(ct);
await bus.PublishAsync(new OrderPlaced(order.Id, order.Lines), ct);

It looks correct. But the database and the Broker are two separate systems. No transaction covers both of them together. This problem is called Dual Write.

Order serviceDatabase1. Save orderOrder savedKafka2. Send messageNo message arrivedThe app crashed hereThere is no shared transaction between the two jobs
If the app crashes right between the two lines, the order is saved but its message never goes out.

What can go wrong?

  1. The app crashes between the two lines. For example, during a new release, the Pod is shut down. The order exists, the message does not. Inventory never reserves the items.
  2. The Broker is not available for a few seconds. Sending the message fails. The order is already saved and does not roll back.
  3. If we swap the order, the problem flips. If we send the message first and then the save fails, inventory reserves items for an order that does not exist at all.
A hidden bug: You almost never see this problem in the development environment. It only happens in Production, during releases or short outages. That is why it is hard to find.

The Outbox idea: write the message to the database too

The database guarantees only one thing: all writes inside one transaction happen together. So we write the message to the same database too:

  1. In one transaction, we save both the order and a row in the OutboxMessages table. This row is the message text.
  2. A background job (Relay) reads the unsent rows and sends them to the Broker.
  3. After a successful send, it marks the row as “sent”.
Order serviceOne database transactionOrdersOutboxMessagesMessage text and send timeEither both are saved, or neitherBackground jobRelayReads the unsent rowsBrokerSends, then marks
The database guarantees that the order and the message are saved together. Sending happens later and separately.

Why does this solve the problem?

  1. If the transaction succeeded, the message is surely in the table.
  2. If the app crashed, after it starts again, the background job finds the same row and sends it.
  3. If the Broker was down for a few minutes, the messages wait in the table. No message is lost.
But we get duplicate messages: The background job may send the message and crash right before marking it. Next time it sends the same message again. So Outbox gives “at least once” (at-least-once) delivery, not “exactly once”. The receiver must handle duplicate messages.

The Inbox idea: recognize duplicate messages

Duplicate messages do not only come from Outbox. Kafka and RabbitMQ may also deliver a message again. For example, the consumer processed the message but crashed before confirming it (an Ack, or a Commit of the Offset).

The Inbox pattern is the mirror of Outbox on the receiver side:

  1. Each message has a unique ID (MessageId).
  2. The inventory service has a table called InboxMessages. Its primary key is the message ID.
  3. In one transaction, it both records the message ID in the Inbox and reserves the items.
  4. If the same message arrives again, recording the ID fails with a duplicate key error. The whole transaction rolls back and the items are not reserved again.
  5. We confirm (Ack) the message only after a successful save.
Brokermsg 42msg 42Arrived againInventory: one transactionInboxMessagesReservationsPrimary key: message IDReservedDuplicate, rejectedRecording the ID a second time fails, so the work is not done again
With a unique key, the database guarantees each message has an effect only once, even on several Pods.
Note: The Inbox pattern is one way to build an Idempotent consumer. If the work is naturally repeatable, for example “set the order status to paid”, maybe you do not need an Inbox table. The Idempotency lesson explains this in more detail.

Live example

Turn the options on or off and run each scenario. See how many reservations inventory records in each case:

Live example: one order, from the database to inventory
0Orders in database
-Outbox row
0Messages in Broker
0Reservations in inventory
Choose the options and run a scenario.

    Code

    Saving the order and the message in one transaction

    The Outbox table is simple: the message ID, the type, the text and the send time.

    public sealed class OutboxMessage
    {
        public long Id { get; init; }                         // keeps the order of messages
        public Guid MessageId { get; init; } = Guid.NewGuid(); // consumers use it to find duplicates
        public required string Type { get; init; }
        public required string Payload { get; init; }
        public DateTimeOffset? SentAt { get; set; }
    }

    In EF Core, one call to the SaveChangesAsync method is a transaction by itself. So both rows are saved together:

    app.MapPost("/orders", async (PlaceOrder cmd, ShopDbContext db, CancellationToken ct) =>
    {
        var order = Order.Place(cmd.CustomerId, cmd.Lines);
        db.Orders.Add(order);
        db.OutboxMessages.Add(new OutboxMessage
        {
            Type = nameof(OrderPlaced),
            Payload = JsonSerializer.Serialize(new OrderPlaced(order.Id, order.Lines))
        });
    
        await db.SaveChangesAsync(ct); // one transaction: order + message
        return Results.Created($"/orders/{order.Id}", order.Id);
    });

    The background job that sends the messages

    public sealed class OutboxRelay(
        IServiceScopeFactory scopes, IMessagePublisher publisher, ILogger<OutboxRelay> logger)
        : BackgroundService
    {
        protected override async Task ExecuteAsync(CancellationToken ct)
        {
            using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
            while (await timer.WaitForNextTickAsync(ct))
            {
                try { await SendBatchAsync(ct); }
                catch (Exception ex) when (!ct.IsCancellationRequested)
                {
                    logger.LogError(ex, "Outbox batch failed. Will try again.");
                }
            }
        }
    
        private async Task SendBatchAsync(CancellationToken ct)
        {
            await using var scope = scopes.CreateAsyncScope();
            var db = scope.ServiceProvider.GetRequiredService<ShopDbContext>();
    
            var batch = await db.OutboxMessages
                .Where(m => m.SentAt == null)
                .OrderBy(m => m.Id)
                .Take(100)
                .ToListAsync(ct);
    
            foreach (var message in batch)
            {
                // IMessagePublisher is our own small wrapper around Kafka or RabbitMQ.
                await publisher.PublishAsync(message.MessageId, message.Type, message.Payload, ct);
                message.SentAt = DateTimeOffset.UtcNow;
                await db.SaveChangesAsync(ct);
            }
        }
    }

    If the app crashes between sending and saving the mark, the message is sent again. This is normal. The Inbox on the receiver side catches it.

    A consumer with Inbox

    public sealed class OrderPlacedHandler(InventoryDbContext db)
    {
        public async Task HandleAsync(Guid messageId, OrderPlaced message, CancellationToken ct)
        {
            db.InboxMessages.Add(new InboxMessage(messageId, DateTimeOffset.UtcNow));
            db.Reservations.Add(Reservation.For(message.OrderId, message.Lines));
    
            try
            {
                await db.SaveChangesAsync(ct); // inbox row + reservation, together
            }
            catch (DbUpdateException ex) when (IsDuplicateKey(ex))
            {
                // Same MessageId was processed before: do nothing.
            }
            // Ack the message only after this method returns.
        }
    
        private static bool IsDuplicateKey(DbUpdateException ex) =>
            ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };
    }

    How you detect a duplicate key error depends on the database. Here it is PostgreSQL with the Npgsql library.

    Ready-made: MassTransit

    You do not have to write all of this yourself. The MassTransit library has a ready-made Outbox for EF Core. It builds the same tables and background job, and it also detects duplicate messages on the receiver side:

    builder.Services.AddMassTransit(x =>
    {
        x.AddEntityFrameworkOutbox<ShopDbContext>(o =>
        {
            o.UsePostgres();
            o.UseBusOutbox(); // published messages go to the outbox table first
        });
    
        x.UsingRabbitMq((context, cfg) => cfg.ConfigureEndpoints(context));
    });
    Another way: Instead of a background job, you can use CDC (Change Data Capture). A tool like Debezium reads the change log of the database and sends new Outbox rows to Kafka. It needs less code, but it adds one more piece of infrastructure to the system.

    Important rules

    1. The message and the data in one transaction. If the Outbox row is saved in a different transaction, the same Dual Write problem comes back.
    2. Expect duplicate messages. Outbox gives at-least-once delivery. So every consumer must be Idempotent.
    3. Each message has a unique ID. This ID stays the same in all send attempts. The receiver uses it to recognize duplicates.
    4. Confirm the message after saving. If you Ack before processing and the app crashes, the message is lost.
    5. Be careful with several Pods. If several copies of the background job read the same row at the same time, the message goes out several times. Either run only one copy, or lock the rows (for example with the FOR UPDATE SKIP LOCKED command in PostgreSQL). If the order of messages matters, make this choice carefully.
    6. Clean up the tables. Delete old Outbox and Inbox rows after a while. Keep the Inbox table at least as long as the longest possible time for a duplicate message to arrive.
    7. Treat work outside the database separately. Inbox makes only changes in the same database happen exactly once. If you send an SMS and crash before saving, the SMS goes out again. For this kind of work, give the outside service a unique ID, if it supports one.

    Common mistakes

    Mistake Result Right way
    Save to the database, then send the message directly If the app crashes, the message is lost. The Outbox pattern.
    Send the message inside the transaction, before the commit The message goes out, but the transaction rolls back. Send only from the Outbox table.
    Believing in “exactly once” A duplicate message does the work twice. An Idempotent consumer or an Inbox.
    First check the Inbox, then do the work, then record Two messages at the same time both pass the check. Record the ID and do the work in one transaction with a unique key.
    Confirm the message before processing If the app crashes, the message is lost. Confirm after a successful save.
    Several Relays without a lock Each message is sent several times. One copy, or row locks.
    Never cleaning the tables The table grows and queries get slow. Delete old rows regularly.

    When to use Outbox and Inbox?

    Good fit

    • The data change and the message must stay in sync.
    • A lost message costs something: an order without a reservation, a payment without a receipt.
    • Several services work together with messages and a Saga.

    Not needed

    • The message is only an unimportant notification, and losing it does not matter.
    • No database changes at all, and only a message is sent.
    • Everything is in one app and one database. A normal transaction is enough.

    Summary in six lines

    1. Writing to the database and sending a message do not share a transaction (Dual Write).
    2. With Outbox, the message is saved together with the data in one transaction.
    3. A background job takes the messages from the table and sends them.
    4. The result is at-least-once delivery. So we get duplicate messages.
    5. With Inbox, the receiver records the message ID together with its work in one transaction, so duplicates have no effect.
    6. Confirm the message after saving, clean up the tables and be careful with several Pods.