Nicolas Ruiz FR
← Back

Think outbox pattern

9 min read architecture · php · messaging

The customer has paid.
The order is in the database.
The parcel, though, never left.

No errors in the logs. No alerts. Just a customer calling support, ten days later.

Yet the code looks perfectly normal:

final class PlaceOrder
{
    public function __construct(
        private OrderRepository $orders,
        private EventBus $events,
    ) {}

    public function __invoke(PlaceOrderCommand $command): void
    {
        $order = Order::place($command->customerId, $command->lines);

        $this->orders->save($order);
        $this->events->publish(new OrderPlaced($order->id()));
    }
}

Here, as we can see, we save to the database, then we send a business event that absolutely must go out. For example, a message in Kafka.

The problem

Between the moment we save and the moment the event has to go out, anything can happen: network problem, latency, broker down, process killed…

The order exists in the database, but the event never goes out. Nobody is notified.

And that's the worst part: a silent bug.
A crash, everyone sees it. This one, to catch it, you've got to hang in there.

Often, it's the customer who finds it. And before even calling support, they may have already left a bad review, or asked for a refund.
That, you can't roll back.

And we can't just put everything in a single SQL transaction either: the database and the broker are two different systems. We can't write to both atomically. This is what we call a dual write.

A hand writing at full speed A Shark Tank investor reacting

The outbox pattern

One way to deal with this is the outbox pattern.

Quite simply, we create an outbox table, and that's where we write our events.

CREATE TABLE outbox (
    id           UUID PRIMARY KEY,
    topic        VARCHAR(255) NOT NULL,
    payload      JSON         NOT NULL,
    created_at   TIMESTAMP    NOT NULL,
    published_at TIMESTAMP    NULL
);

Be careful to purge this table from time to time: otherwise it will never stop growing.

But don't get caught out: we don't do "save the order" then "save the event" separately. We do both in the same transaction.

public function __invoke(PlaceOrderCommand $command): void
{
    $order = Order::place($command->customerId, $command->lines);

    $this->transaction->wrap(function () use ($order) {
        $this->orders->save($order);
        $this->outbox->add(new OrderPlaced($order->id()));
    });
}

If the slightest problem occurs, we roll back. The order and the event are always together: either both are saved, or neither is.

That's all well and good. But when does the event go out, and how?

Sending the event

Once we're sure the order and the event are both saved, we need to plug in something that reads the outbox table and publishes the messages to Kafka. Two options (as far as I know):

With polling, once the outbox row has been read and published, we mark it as sent. We won't read it again. The CDC, for its part, remembers its position in the transaction log and picks up from there.

// Home-made cron example
public function __invoke(): void
{
    foreach ($this->outbox->pending() as $message) {
        $this->kafka->publish($message->topic(), $message->payload());
        $this->outbox->markAsPublished($message);
    }
}

Careful: this is the same problem all over again. We publish to Kafka, then we update the database. This polling doesn't look very safe at first sight.

An endless Escher-style loop

The difference with our first PlaceOrder is that as long as the message isn't considered published, we keep sending it to Kafka. So the message will never be lost. In the first PlaceOrder, we could lose it at any time.

But this can bring new problems.

Idempotence

We now have a system that reads the table, publishes, then updates the event's state. Between the publication and the update, several things can happen: the process crashes, the network drops, the cron is restarted… And in the end, the message can be sent several times: the row is still considered unpublished, so it goes out again.

This is what we call at least once: the message arrives at least once, but maybe several times. There's no "exactly once" guarantee.

At that point, it's up to the recipient of the message to be idempotent.

Behind this barbaric name, it's quite simple: no matter how many times we process the same message, the result must be the same as if we had processed it only once.

For example, on the consumer side:

public function __invoke(OrderPlaced $event): void
{
    if ($this->shipments->existsForOrder($event->orderId)) {
        $this->logger->info('OrderPlaced already processed', ['orderId' => $event->orderId]);

        return;
    }

    // Unique index on shipment.order, to avoid concurrency issues
    $this->shipments->save(Shipment::prepareFor($event->orderId));
}

If the order id already exists in the database, we log "already processed" and stop. And we still acknowledge the message (ack), otherwise the broker will redeliver it.

To handle concurrency, we can add a unique constraint in the database. If two messages arrive at the same time, the exists isn't enough: it's the database that will say no to the second one.

Now imagine that, when we receive the OrderPlaced event, we have listeners / handlers that decrement the stock… How do we avoid decrementing a hundred times if we receive the same message a hundred times?

This time we're talking about the inbox.

Inbox pattern

Sometimes the code isn't enough to handle idempotence, as here with a decrement.

Unlike Shipment, where the received order id is our business key (what identifies the order in our domain), here there's nothing to check: the product doesn't know which order has already decremented it.

So we need an inbox table, just like the outbox. It only serves one purpose: noting the messages already processed.

Where does the identifier come from? From the outbox. The id of the outbox row, generated by the system that sent the event, travels with the message, and that's what the consumer uses.

CREATE TABLE inbox (
    message_id UUID NOT NULL,
    handler VARCHAR(255) NOT NULL,
    processed_at TIMESTAMP NOT NULL,
    PRIMARY KEY (message_id, handler)
);

It's the combination of the message_id (which comes from the outbox) and the handler (the handler's class name) that makes up our primary key. The same OrderPlaced can therefore appear twice: once for the stock, once for the loyalty points.

message_id handler
a3f1… StockHandler
a3f1… LoyaltyPointsHandler
public function __invoke(OrderPlaced $event): void
{
    $this->transaction->wrap(function () use ($event) {
        if (!$this->inbox->markAsProcessed($event->messageId, self::class)) {
            return; // already processed
        }

        foreach ($event->lines as $line) {
            $this->products->get($line->productId)->decreaseStock($line->quantity);
        }
    });
}

markAsProcessed() does a simple insert. It returns false if the primary key already exists.
No if exists here: the database will handle uniqueness and/or concurrency.

Same transaction, same logic as the outbox: either the message is marked AND the stock decremented, or neither. If it crashes, we roll back, and the replayed message will be processed properly.

Like the outbox, this table grows. We can delete the rows older than the maximum redelivery delay (a few days, for example).

Be careful: the inbox only protects what's in our database. An external call (an email, a Stripe payment) can go out even if the transaction is rolled back. It's then up to the third party to deduplicate: that's the role of Stripe's idempotency key, for example.

There is also a more advanced variant of the inbox1.

When should we put an outbox in our app?

When the event absolutely must go out and notify another application / another system, as in this article's example: a completed purchase. The shipping platform absolutely must be notified, otherwise the customer will never get their parcel.

And when can we do without it? For a welcome email, for example. The consequence of it not being received is very small (depending on the business, of course), compared to a parcel that will never ship despite a purchase.

The more it costs to lose the event, the more the outbox is justified.

And the inbox? It's useful when the processing isn't naturally idempotent and there's no business key to protect.

Conclusion

The outbox pattern is a simple but effective system to make sure that no business data is saved without its event eventually going out.

The outbox guarantees that the message goes out. The inbox guarantees that it's processed only once.

  1. Some go further: they store the whole message (the payload) in the inbox, acknowledge the broker right away, and a worker loops over the table to process the messages, like the outbox relay. Useful to acknowledge quickly, replay, or handle retries and ordering yourself. ↩