0% read
    ← All stories
    ––
    Distributed Systems · Field Notes

    The Outbox Pattern
    The Silent Hero

    Nobody talks about it. It never wins an architecture review on looks. But it keeps more production systems running than many of the tools people love to show off.

    scroll

    Every distributed system has its famous parts. Service meshes get talked about in conference keynotes. Kafka gets big architecture diagrams. Kubernetes even has its own job titles. But one small part makes sure your events are never lost. It lets an order survive a broker outage and a pod restart, even on the same bad day. Almost nobody gives it credit. It just does its work in the background.

    This part is called the Outbox pattern. It will never trend on LinkedIn, and it will never win a design review because it looks smart. Still, it holds up more production systems than most of the technologies people like to discuss.

    01 — THE PROBLEMThe problem it solves

    Sooner or later, every service faces the same situation. It has to save something in its own database, and it has to tell the rest of the system what happened. For example, an order was placed, a payment went through, or a user signed up. So two things must happen together: a write to the local database, and a message sent to a message broker. If only one of them happens, the system starts giving out wrong information.

    Most of us first write it the simple way:

    OrderService.cs — the dual write
    public async Task PlaceOrder(Order order)
    {
        await _dbContext.Orders.AddAsync(order);
        await _dbContext.SaveChangesAsync();          // (1) DB commit
    
        await _serviceBus.PublishAsync(new OrderPlaced(order.Id)); // (2) broker publish
    }

    This code talks to two separate systems, so it can fail in two separate places, and nothing ties the two steps together. Suppose step (1) works but step (2) fails. Maybe Service Bus timed out for a moment, the network dropped, or Kubernetes killed the pod in the middle of the request. Now the database says the order exists, but no other service ever hears about it. No invoice, no delivery, no confirmation email. If you swap the steps and send the message first, you get the opposite problem: an event about an order that was never saved.

    You cannot put a database save and a broker publish inside one all-or-nothing step. Distributed transactions can technically do it, but they are slow, easy to break, and most cloud brokers do not support them well. Hardly anyone uses them today. This is exactly the problem the Outbox pattern solves, and it needs only one extra database table.

    Two systems, two ways to fail — and a moment when your database starts telling lies.

    02 — THE SOLUTIONWhat the pattern does instead

    Instead of writing to two systems, the Outbox pattern writes to only one. The message that needs to go out is saved in the same database as the business data, inside the same transaction.

    OrderService.cs — one atomic commit
    using var tx = await _dbContext.Database.BeginTransactionAsync();
    
    await _dbContext.Orders.AddAsync(order);
    
    await _dbContext.OutboxMessages.AddAsync(new OutboxMessage
    {
        Id          = Guid.NewGuid(),
        Type        = nameof(OrderPlaced),
        Payload     = JsonSerializer.Serialize(new OrderPlaced(order.Id)),
        OccurredOn  = DateTime.UtcNow,
        ProcessedOn = null
    });
    
    await _dbContext.SaveChangesAsync();
    await tx.CommitAsync();

    Now the business data and the message are saved together in one commit. Either both are saved, or neither is. You can never have one without the other. A hard problem across two systems has become a simple transaction inside one database, and every relational database has handled that reliably for decades.

    Service PlaceOrder() ONE TRANSACTION Orders table business fact Outbox table intent-to-publish Relay background loop Broker Service Bus
    The service writes both rows in one commit. A separate relay reads the outbox table and sends the messages to the broker, outside the main request path.

    A separate background process, called the relay, picks up the rows that are not yet sent and publishes them to the broker. Once a message is sent successfully, the relay marks that row as done.

    OutboxRelay.cs — the quiet drain
    public async Task RelayLoop(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var pending = await _dbContext.OutboxMessages
                .Where(m => m.ProcessedOn == null)
                .OrderBy(m => m.OccurredOn)
                .Take(50)
                .ToListAsync(ct);
    
            foreach (var msg in pending)
            {
                await _serviceBus.PublishAsync(msg.Type, msg.Payload);
                msg.ProcessedOn = DateTime.UtcNow;
                await _dbContext.SaveChangesAsync(ct);
            }
    
            await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
        }
    }

    No drama and no fancy infrastructure. Just a table, a transaction and a loop, doing the one job nobody else was ready to guarantee.

    03 — THE BENEFITSBenefits you notice only when they are missing

    The easiest way to see the value of this pattern is to look at what stops going wrong once it is in place. It does more than fix correctness. It also reduces the load on every service that uses it.

    First, the request no longer waits for the broker. In the simple version, the PlaceOrder request is stuck until the broker confirms the message. When the broker is slow, your API is slow too. With the Outbox pattern, the request saves locally and replies at once, and the publishing happens separately in the background. Even your slowest requests no longer depend on the broker's health.

    Second, a broker outage no longer becomes your outage. Suppose the broker is down for ninety seconds. A simple service either fails the requests, or drops the events and still says "success". A service with the Outbox pattern keeps accepting orders the whole time. Messages collect in the table and go out once the broker is back. The outage is absorbed by the same database you were already writing to.

    BROKER DOWN recovery → drain writes keep succeeding → outbox backlog
    The green writes never stop. The amber backlog safely grows in the table during the outage, then drains the moment the broker returns.

    Third, retries move out of the user's way. If a publish fails for a moment, the relay retries it in the background. The customer has already got their 201 Created response. Nobody has to wait while a message is sent again.

    Fourth, you handle traffic spikes for free. During a rush, the table grows, and the relay empties it at a steady speed. The service stores the burst safely in the database, instead of trying to publish every message at peak load.

    Put all this together and each request does less work. It holds threads for less time, and it depends on fewer systems being healthy at the exact moment a user is waiting. The Outbox pattern carries that load so the rest of the service does not have to.

    The systems that stay reliable on their worst days are usually the ones that remembered to use this pattern.

    04 — THE ONE RULEThe one thing it asks of you

    Every reliable pattern comes with a condition. The Outbox pattern promises that each message is delivered at least once, which means sometimes more than once. If the relay sends a message and crashes before it marks the row as done (ProcessedOn), it will send that message again after restarting. That is on purpose — it is why you never lose an event. But it also means the services that receive these messages must be safe to repeat: handling the same message twice must give the same result as handling it once. Check the message ID and skip duplicates, use upserts, and design the receiving step so repeating it causes no harm. Follow this one rule and the pattern will not let you down. Ignore it, and the first time a relay pod restarts in the middle of a batch, customers will get two invoices.

    05 — THE PATROLHow the relay keeps watch

    Polling is the simplest way, and it works everywhere. Every few seconds, the relay asks the database for rows that are not yet sent. It adds a small delay and a small, steady amount of querying. Put an index on ProcessedOn and it will handle much more load than you expect.

    Change Data Capture (CDC) reads the database's own transaction log instead of asking again and again. On Azure, a common setup uses Debezium to read changes from SQL Server or PostgreSQL and push them into Event Hubs or Kafka. Messages go out faster and the polling load disappears, but there is more infrastructure to run. Use CDC only when your message volume is high enough that the polling delay actually shows up in your dashboards.

    06 — THE GEARRunning it well on Azure and Kubernetes

    07 — KNOWING WHEN TO RESTWhen you do not need this pattern

    Do not use this pattern everywhere. It needs a table, a relay, regular clean-up and some effort to run. If a service sends no events, or if losing an occasional notification really does no harm, skip it. Use it when a change in your service must reach other services, for example orders, payments or account setup, where a missing record is a real business problem.

    ◆

    The key takeaway

    Nobody will praise your architecture because of the Outbox pattern. It has no dashboard to show off and no logo to frame. It takes a hard problem across two systems and turns it into a simple one inside one database. Then it keeps running in the background, so your services reply quickly, survive broker outages, and store traffic spikes safely instead of making users wait.

    That is the whole idea: do the difficult, important work so reliably that everyone forgets it is even there.

    ← Back to all stories
    ––

    Discussion