TransactionsGENERALFRAMEWORK-SPECIFICDATABASE-SPECIFIC

Where the Transaction Boundary Goes

Which operations must commit together, which merely happen nearby, and why "the whole handler" is almost never the right answer.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

Which of these operations belong inside the same transaction, and which are just adjacent in time?

The requirement

Checkout must create an order, reserve stock, charge a card, send a confirmation email and publish an order.placed event. Someone has to say which of those five are atomic with each other.

The obvious build

Open a transaction at the start of the handler and commit at the end. Everything the request does is then atomic, which is obviously the safest option.

Why it breaks

The card charge is an HTTP call to another company. It is now inside BEGIN...COMMIT, holding a connection and every lock the earlier writes took, for as long as the payment provider takes to answer (External Calls Inside a Transaction).

How it breaks in production
  • The card charge is an HTTP call to another company. It is now inside BEGIN...COMMIT, holding a connection and every lock the earlier writes took, for as long as the payment provider takes to answer (External Calls Inside a Transaction).
  • The email cannot be rolled back. If the transaction aborts after it is sent, the customer has a confirmation for an order that does not exist.
  • The event publish is not a database write, so committing the transaction and publishing the event are two separate things that can disagree (The Dual Write Problem).
  • The boundary drifts. Six months later someone adds an audit call and an image resize inside the same handler, and the transaction now spans work nobody remembers is in it.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • The boundary is defined by an invariant: two writes belong in one transaction when there is a rule that must never be observed as broken. An order with no line items violates a rule; an order without its confirmation email does not.
  • Everything inside the boundary must be rollback-able, which in practice means it must be a write to this database. Nothing else can be undone by ROLLBACK.
  • Everything inside the boundary is also serial in resource terms: it holds one connection and its locks for the whole duration, so the boundary is a cost as well as a guarantee.
  • Work that is required but not atomic goes *after* the commit, made reliable by a different mechanism — a durable job, an outbox row, a reconciliation pass (The Transactional Outbox).
  • Work that is atomic but not required is a design smell: if it can be dropped, it does not need the guarantee.

Ask what must never be observed as broken

The boundary question has a single reliable test. For each pair of operations, ask: is there a state in which the first has happened and the second has not, and would that state be wrong — not inconvenient, wrong? If yes, they commit together. If no, they do not.

Run the checkout example through it. Order and line items: an order with no items is wrong, so they are atomic. Order and stock reservation: overselling is wrong, so they are atomic. Order and payment: payment is another company's system, so it cannot be inside — the design must handle "charged but no order" and "order but not charged" explicitly. Order and email: an unsent email is a missing notification, not a broken invariant. Order and event: the event must not be lost, and losing it is a durability problem, not an atomicity one.

Where does this operation go?

Does this operation need to be atomic with the write, and can it be?

Inside the transaction

when It is a write to this database, and an invariant would be visibly broken if it did not happen with the others.

cost Holds a connection and locks for the whole bracket; every addition here reduces concurrency.

Inside, as an outbox row

when It must not be lost, but it is not a database write — an event, a job, a notification.

cost A table, a background reader, and at-least-once delivery downstream (The Transactional Outbox).

After the commit, via a durable job

when It must happen but not atomically: emails, search indexing, thumbnails, webhooks.

cost An eventual-consistency window and an idempotency requirement on the job (Job Idempotency).

After the commit, best effort

when Losing it is genuinely acceptable: a cache warm, a metric, a nice-to-have notification.

cost It will sometimes not happen. Say so out loud, so nobody later assumes it always does.

Before the transaction

when Validation, authorization, external lookups, price quotes — reads that inform the write.

cost What you read may have changed by the time you write; the write must re-check (Backend Races).

The same handler, two boundaries

Seeing both versions side by side makes the cost concrete. The difference is not style: it is how long a pooled connection is held, which locks are held while an external system is thinking, and whether an unrollbackable side effect sits inside a bracket that can roll back.

Checkout
Transaction around the whole handler
await withTransaction(async (tx) => {
  const order = await createOrder(tx, input)
  await reserveStock(tx, input.items)
  await payments.charge(input.card, order.total)   // HTTP, seconds
  await email.sendConfirmation(order)              // cannot roll back
  await broker.publish('order.placed', order)      // not this database
})
Transaction around the invariant
const auth = await payments.authorize(input.card, quote)  // before BEGIN

const order = await withTransaction(async (tx) => {
  const o = await createOrder(tx, input, auth.id)
  await reserveStock(tx, input.items)
  await tx.query(
    'INSERT INTO outbox (topic, payload) VALUES ($1, $2)',
    ['order.placed', JSON.stringify(o)],
  )
  return o
})

await jobs.enqueue('send-confirmation', { orderId: order.id })  // idempotent

The second holds the connection only for two writes and an insert. The payment authorization happens before BEGIN, so no lock is held while another company's API thinks. The event is durable because it is a row in the same transaction rather than a network call that might not match the commit. The email is a retriable job rather than an irreversible side effect inside a reversible bracket.

How boundaries go wrong

Both directions fail. Too wide and you hold resources and trap irreversible effects; too narrow and you reintroduce exactly the partial state the transaction was for. The rows below are the specific shapes those take.

Boundary failures
TriggerSymptomCauseResponse
External call inside the bracketPool saturated while database CPU is near idleConnections and locks held for the duration of an HTTP callMove the call before or after; keep the bracket to database writes (External Calls Inside a Transaction)
Email sent inside the bracketCustomers receive confirmations for orders that do not existAn unrollbackable effect inside a rollback-able bracketEnqueue after commit; make the job idempotent
Order and items in separate transactionsOrders with no line items appear at a low rateCrash or error between the two commitsOne transaction — this is the invariant the boundary exists for
Cache invalidated before commitStale reads that persist long after the writeA concurrent reader repopulated the cache with the pre-commit valueInvalidate after commit, and treat the invalidation itself as fallible (Cache Invalidation)
Publish after commit, broker downDownstream never learns the order existsCommit and publish are two systems with no shared atomicityOutbox row inside the transaction, published by a reader (The Dual Write Problem)
Framework opens a transaction per requestLong transactions on read-only endpoints; vacuum falls behindA default boundary nobody choseScope transactions explicitly in the service layer instead (The Service Layer)

How to build it

Most important first.

  • Write down the invariant before choosing the boundary. "An order always has at least one line item" tells you exactly which two inserts commit together.
  • Put only database writes inside. External calls, file writes, cache updates, emails and message publishes go outside (External Calls Inside a Transaction).
  • Do the reads and the validation you can before BEGIN, so the bracket contains writes and the checks that must be atomic with them.
  • If an event must not be lost, write it to an outbox table inside the transaction and publish it afterwards from a reader. That converts a cross-system problem into a same-database one (The Transactional Outbox).
  • Make the boundary explicit and local — one withTransaction call whose body you can read on one screen — rather than a session opened by middleware.
  • Anything you place after the commit must be idempotent, because it can run twice when the request is retried (Idempotency in Backends).

What can go wrong

Failure modes
  • A boundary that grew: the handler is a transaction and the handler now does five unrelated things, three of them slow.
  • A boundary too tight: the order commits, the line items are a second transaction, and a crash in between leaves the broken state the transaction existed to prevent.
  • Post-commit work that is not durable: the process dies after the commit and before the enqueue, and the email is never sent by anyone (Background Jobs).
  • A cache invalidated before the commit, so a concurrent reader repopulates it with the pre-commit value and the stale entry outlives the write (Cache Invalidation).
  • A framework-owned request-scoped transaction that nobody realises is there, so a "no transaction" code path is silently inside one (What an ORM Actually Does).
What can race
  • Between two transactions there is a window in which the intermediate state is visible to other requests. Whether that is acceptable is a product question, and it must be asked explicitly.
  • Post-commit work racing the client: the client receives the response and immediately reads, before the asynchronous effect has happened (Eventual Consistency in Practice).
  • Two concurrent checkouts reserving the last unit of stock — the boundary decides atomicity, not exclusivity. You still need a conditional update or a lock (Atomic Operations).
Security
  • Authorization decisions must be made before the writes and re-checked against the rows actually loaded. A transaction does not make an unauthorized write acceptable (Object-Level Authorization).
  • Audit rows generally belong inside the boundary: an audit log that can be missing an entry for a committed change is not evidence of anything (Audit Logs for Privileged Actions).
  • Security-relevant post-commit work — revoking a session, propagating a permission change — needs the same durability treatment as any other post-commit effect, or a revocation can be lost.
Misreads
  • "Wrap the whole handler and you are safe." You are safe from partial writes and exposed to held locks, held connections and unrollbackable side effects.
  • "If it is in the same request it should be in the same transaction." Requests are a delivery mechanism. Invariants define boundaries.
  • "Sending the email inside the transaction is safer because it rolls back." Sending an email is not a database write and does not roll back.
  • "Two transactions are always worse than one." Two short transactions with a well-defined intermediate state are frequently better than one long one — that is the next lesson (One Transaction or Two).

Operating it

How you see it in production
  • Transaction duration by handler. A boundary that includes an external call shows up as a bimodal or long-tailed distribution rather than a slow average.
  • Statement count per transaction; a growing count over releases is the boundary drifting.
  • A trace where the transaction span visibly contains an HTTP client span is the definitive picture of a boundary problem (Reading the Waterfall).
  • Count post-commit failures separately from in-transaction failures — they mean completely different things to the caller and to the data.
What changes at 10x and 100x
  • The wider the boundary, the fewer concurrent requests the same pool and the same rows can support. Narrowing the boundary is often a bigger throughput win than any query tuning (Connection Pools).
  • At higher scale the pattern converges: one short transaction that writes the state plus an outbox row, then everything else asynchronously.
  • Multi-service systems cannot share a boundary at all, and the question becomes which service owns the invariant — usually one, not several (Saga Pattern).
What this costs
  • Narrow boundaries mean more states the system can be in between steps, and those states must be legal and recoverable. That is real design work you are choosing to do.
  • Post-commit work traded for atomicity gains throughput and costs you an eventual-consistency window the product must tolerate (Eventual Consistency in Practice).
  • An outbox gives you reliability at the cost of a table, a background reader and at-least-once semantics downstream.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALThe invariant-defines-the-boundary rule is independent of engine, language and framework.
  • FRAMEWORK-SPECIFICFrameworks with request-scoped sessions (Spring's @Transactional on a service method, Django's ATOMIC_REQUESTS, a SQLAlchemy session bound to the request) choose a boundary for you. Where that default sits — per request, per service call, per handler — changes what "outside the transaction" even means in your codebase.
  • DATABASE-SPECIFICA wide boundary is more expensive on Postgres than the lock cost alone suggests: a long-lived transaction holds back the xmin horizon so vacuum cannot reclaim dead row versions anywhere in the database. InnoDB pays a related cost through a growing undo history. Both punish long transactions; the symptom differs.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.