No history yet

The Outbox Pattern

Transcript

Beau

Okay, Jo. So, last time we settled on the Saga Orchestrator as our 'boss' for the ERP. The Order Manager. It tells the Inventory service what to do, then the Shipping service... I get it. It makes sense. But there's this one little thing nagging me.

Jo

Oh yeah? What's that?

Beau

It's that moment... the moment the orchestrator has to do two things at once. It has to, say, update its own database—in our case, PostgreSQL—to say 'Okay, I have now told the inventory service to reserve the stock'. And *then* it has to actually send the message. What if it crashes right in between those two steps?

Jo

You found it. That is *the* critical failure point. It's the Two Generals' Problem all over again, but this time it's happening inside a single service. You're trying to achieve a single atomic action—update a database and send a message—across two completely different systems that don't speak the same transactional language.

Beau

Right! Because my PostgreSQL transaction has no idea what a 'message bus' is. It can't include sending a network request in its ACID guarantee. So the database commit could succeed, and the message send fails... or the message goes out but the database write fails.

Jo

Exactly. And you end up with a system that is lying to itself. The orchestrator's database says 'I sent the command', but the command was never sent. The whole saga just stops, dead in its tracks, and you might not even know it.

Beau

So... how do we fix that? We can't just cross our fingers and hope the server doesn't crash at that exact millisecond.

Jo

We don't. We solve it with something called the Outbox Pattern. And the core idea is deceptively simple: don't try to do two things at once. Just focus on what you can control with 100% certainty, which is your own PostgreSQL database.

Beau

The Outbox Pattern. Okay, like an email outbox? Something waiting to be sent?

Jo

Precisely that analogy. Instead of trying to send the message immediately, you just... write the message down. You create another table in your same PostgreSQL database, and you call it, say, `outbox`. And here's the magic trick: you write your business data—like updating the order status—and you insert a row into the `outbox` table inside the *exact same database transaction*.

Beau

Oh! Okay. So because it's all one transaction, we get our ACID guarantee back. Either both the order status gets updated AND the 'message to be sent' gets saved, or neither of them do. It's atomic again.

Jo

You got it. We've taken the unreliable, external action—sending a message—and turned it into a reliable, internal action: writing a row to a table. The state of our business and the *intent* to notify the outside world are now saved together, atomically.

Beau

But... the message is still just sitting in a database table. It hasn't actually gone anywhere. How does it get from the outbox table to, you know, the actual outside world?

Jo

That's the second half of the pattern. You have a separate process. It's often called a 'Relay' or a 'Poller'. It's a simple, little worker whose only job in life is to wake up every few seconds, check the `outbox` table for new, unprocessed messages...

Beau

...grabs one, tries to send it...

Jo

Exactly. It tries to send it. If it succeeds, it goes back to the outbox table and marks that row as 'processed', maybe with a timestamp. If sending fails—the network is down, whatever—it does nothing. It just leaves the message there, and it will try again on its next pass.

Beau

So that gives us reliability. If the relay process crashes, it doesn't matter. When it comes back up, the unprocessed message is still sitting right there in the outbox, waiting patiently.

Jo

Right. This pattern guarantees what we call 'at-least-once' delivery. The message will be sent at least one time. Now, there is a catch. What happens if the relay sends the message successfully, but then crashes before it can mark the row as processed?

Beau

Ah. When the relay restarts, it'll see that unprocessed message and send it... again. So the receiving service might get the same message twice.

Jo

And that's the trade-off. But we've talked about this before—it's idempotency. The services that receive these messages just have to be designed to handle duplicates. They check an ID and if they've seen it before, they just ignore it. It's a much easier problem to solve than a message that's lost forever.

Beau

That makes total sense. We're trading a catastrophic 'maybe it sent' problem for a manageable 'it might send twice' problem. I can live with that. So, in PostgreSQL, what does this outbox table actually look like? Is it complicated?

Jo

Not at all. It's usually very simple. You'd have an ID, maybe a UUID. A column for the event type or topic, like 'OrderReadyToShip'. A column for the actual data, the payload, which is often just a JSON blob. And then a `processed_at` timestamp that is nullable. When the poller processes it, it just fills in that timestamp.

Beau

So the poller's query is just 'SELECT * FROM outbox WHERE processed_at IS NULL'. Simple.

Jo

Basically, yes. A neat little PostgreSQL tip here is that if you want to run multiple copies of your relay for performance, you can use a query like `SELECT ... FOR UPDATE SKIP LOCKED`. This lets each poller instance grab a different set of rows to work on without them tripping over each other. It's really efficient.

Beau

So we've essentially built a durable, transaction-safe queue right inside our own database. We've bridged the gap between the certain world of our PostgreSQL server and the uncertain world of the network.

Jo

That's the perfect way to put it. We used the tool we can trust—the local ACID transaction—to solve the problem we can't trust, which is communication with anything else. It's the most practical solution to that little Two Generals' dilemma that happens thousands of times a day inside our services.