Here is a failure that reaches production more often than it should. A customer pays. Inside the same transaction, the code writes the order, then calls the SMS provider to send a confirmation. The SMS provider times out. The transaction rolls back.

The money has moved. The order has not. Nobody finds out until the customer calls.

Why it happens

The instinct is right — the order and its side effects should be consistent — but the implementation is wrong. A database transaction can only roll back database work. It has no power over an HTTP call that already left the building, and it has no way to know whether a timed-out call actually arrived.

Putting an external call inside a transaction gives you two bad outcomes and no good one: either you hold a database lock open for the duration of somebody else's network, or you roll back work that already happened elsewhere.

The outbox pattern

Split it in two.

Inside the transaction, write the order and write a row to an outbox table describing what should happen next: send this SMS, issue this invoice, update this feed. Both writes commit together or neither does.

Outside the transaction, a background dispatcher reads unprocessed outbox rows, performs the side effects, and marks them done.

The transaction is now short and purely local. The side effect is guaranteed to happen eventually, because the intention is durable — it is a committed row, not a call that may or may not have left.

What the dispatcher needs

Retry with backoff. A provider that is down for thirty seconds should not consume a thousand retries. Exponential backoff, with a ceiling.

A dead-letter destination. After a bounded number of attempts, the row moves somewhere a human looks. Silent infinite retry is how you discover in March that nothing has sent since January.

Idempotency at the consumer. The dispatcher may deliver twice — that is the trade for guaranteed delivery. The receiving side has to tolerate it, usually with a key on the event id.

Ordering only where it matters. Global ordering is expensive and rarely needed. Ordering per aggregate — all events for one order in sequence — is usually enough and much cheaper.

Where the boundary sits

The rule of thumb: anything that leaves your process goes in the outbox. Payment provider callbacks, SMS, email, search index updates, feeds to comparison sites, notifications to another module.

Anything that stays inside your database can go in the transaction.

In production

In a pharmacy platform we built, SMS, invoicing and the product feed to a comparison service all run through a message queue rather than the request path, so placing an order never waits on any of them.

In an education platform, 44 integration events cross module boundaries this way — granting a course after payment, crediting a wallet after a refund, calculating a partner's commission — each with backoff retry and a dead-letter queue.

And in a marketplace built for the German market, the same pattern carries events between fifteen bounded contexts, so a failure in one does not roll back another.

The one-line version

Commit the intention with the data. Perform the action afterwards. Never hold a transaction open across a network you do not control.