skip to content

Inside a database transaction, your application code also calls an external payment API over HTTP. The transaction later rolls back. What does the database's atomicity guarantee say about that HTTP call, and how do teams handle the mismatch?

level: seniorimportance: should knowfreq 42%

answer

  1. atomicity covers this database only
  2. dual write: no ordering fixes it
  3. commit the intent, act after commit
  4. at-least-once → external call must be idempotent
  5. never hold a transaction across a network call

basics

~20 s

Nothing — atomicity covers only changes inside that database. The HTTP call already happened and is not reversed. Teams keep the external effect out of the transaction: record the intent in a table in the same transaction, and let a separate worker perform the call after commit, idempotently.

solid answer

~60 s

Atomicity is a property of the database's own changes. The HTTP call left the process and reached another system; the database has no way to reverse it, so after a rollback you have a payment attempted with no record of it — or, in the reverse ordering, a committed row for a call that never succeeded. This is the dual-write problem: two systems, one logical unit, no shared commit. The usual handling is to make one of the two systems authoritative and derive the other from it. Write the *intent* — a row in an outbox or job table — inside the same transaction as the business change, so those two are genuinely atomic together. After commit, a separate worker reads that row and performs the call, retrying until it succeeds. That converts the problem into at-least-once delivery, which is why the external call must be idempotent — typically by sending a caller-generated key the provider uses to deduplicate retries. Two-phase commit across a database and an HTTP API is generally not available and rarely worth it.

code

sql · 9 lines
sql
BEGIN;
  INSERT INTO orders (id, customer_id, amount) VALUES (123, 7, 40.00);

  INSERT INTO outbox (id, kind, payload, status)
  VALUES ('9f2c-...', 'charge_card',
          '{"order_id":123,"amount":40.00}', 'PENDING');
COMMIT;
-- a separate worker claims PENDING rows and performs the HTTP call,
-- sending outbox.id as the provider's idempotency key

go deeper

for a junior

Know that a rollback undoes database changes only, so an email or API call made inside the transaction still happened.

for a middle

Explain the dual-write problem and that the fix is to record the intent in the same transaction and act on it after commit.

for a senior

Design it end to end: outbox table, worker with retries and backoff, idempotency key on the external call, dead-letter handling, and never holding a transaction across a network call.

for a principal

Weigh outbox versus distributed commit versus compensation for the specific failure profile, and own the operational cost — queue growth, poison rows, duplicate-safety guarantees the providers actually offer.

## The boundary of the guarantee A database's atomicity is implemented with undo information and a commit record inside that database. Anything the application does that is *not* a change to that database is outside the mechanism entirely: emails sent, files written, messages published to a broker, calls to third-party APIs, cache entries updated, another database written. So if code inside a transaction block calls a payment API and the transaction then rolls back — because of a constraint violation, a deadlock, a crash, or a deliberate abort — the payment attempt stands. The database returns to its prior state; the outside world does not. ## Both orderings are broken It is worth showing that no ordering of the two operations fixes this on its own. **Call first, then commit.** If the commit fails, the payment happened but no record of it exists. The customer is charged and your system does not know. **Commit first, then call.** If the process dies after the commit, the record says \"paid\" but no payment was ever made. There is no ordering that makes two independent systems commit together, which is precisely why this is called the dual-write problem rather than an ordering bug. ## The standard resolution: make it one write The fix is to stop performing two writes to two systems in one logical step. Instead: 1. In the same transaction as the business change, insert a row describing the *intent* — \"charge order 123 for 40.00\" — into a table (commonly called an outbox, or a job/task queue table). 2. Commit. Now the business change and the recorded intent are atomic with respect to each other, because they are both changes to the same database. 3. A separate worker polls (or is notified of) that table after commit, performs the HTTP call, and marks the row done. If the process dies at any point, the intent survives and the worker retries. Nothing is lost, and nothing is performed on behalf of a transaction that rolled back — because a rolled-back transaction never wrote the intent row in the first place. ## What you must add: idempotency The worker can crash after making the call but before marking the row done, so the call will sometimes be made twice. Delivery is therefore **at-least-once**, and the external operation must tolerate that. The usual mechanism is a caller-generated idempotency key sent with the request — for example the HTTP `Idempotency-Key` header that many payment providers support — derived deterministically from the outbox row so retries carry the same value. The provider recognises the repeat and returns the original result instead of charging again. Where the provider offers no such facility, the fallback is a query-before-act using your own reference id, which is weaker but often sufficient. ## Other consequences and variations **Never hold a transaction open across a network call.** Even ignoring atomicity, doing so pins locks and undo/version state for the duration of an unpredictable remote round trip, and a slow provider becomes database contention. Getting the call out of the transaction fixes this too. **Delivery ordering and volume.** The outbox becomes a queue you now operate: it needs indexes on the pending state, a retry policy with backoff, a dead-letter path for permanently failing rows, and cleanup of completed rows. That is real work — the pattern trades an impossible guarantee for an operable one, not for free simplicity. **Two-phase commit.** A distributed commit protocol can make two resource managers commit atomically, and is genuinely used across two databases or a database and a transactional broker. It is rarely available for a third-party HTTP API, and it introduces blocking behaviour if the coordinator fails, so most teams choose the outbox instead. **Compensation.** Where the external effect is genuinely irreversible in the other direction — the charge succeeded but your commit failed — the answer is a compensating action (a refund) driven from a durable record of what was attempted. Again this requires having written the attempt down somewhere durable first, which is the same insight. ## Interview framing \"Atomicity ends at the database boundary, so the call is not rolled back. I keep external effects out of the transaction: commit the intent as a row in the same transaction, then a worker performs the call after commit, idempotently, retrying until it succeeds.\"

  • Why must the external call be idempotent when driven from an outbox table?
    Because the worker can perform the call successfully and then crash before marking the row processed, so on restart it will call again. Delivery is at-least-once, not exactly-once, and no amount of careful ordering removes that window. Sending a stable caller-generated key derived from the outbox row — such as an HTTP Idempotency-Key — lets the provider recognise the duplicate and return the original result.
  • When would a two-phase commit protocol be a reasonable alternative to the outbox pattern?
    When both participants are real transactional resource managers that support it — typically two databases, or a database and a transactional message broker — and the operation genuinely requires atomicity across them. It is not usually an option for a third-party HTTP API, and it costs availability: if the coordinator fails after the prepare phase, participants hold locks in an in-doubt state until it recovers, so most teams prefer the outbox and idempotent retries.

saying these in an interview costs you the question

  • Believing a rollback can cancel an already-sent HTTP request or email
  • Claiming that calling the API after the commit makes the problem go away
  • Holding the database transaction open across the network call
  • Assuming exactly-once delivery is achievable, so no idempotency key is needed
  • Proposing two-phase commit against a third-party HTTP API as if it were available

context