Skip to the content
Software Made Clear Diagrams that show the mechanism About

Transaction boundaries

Choose which changes must commit together, keep remote calls outside local transactions, and test the failure gap with an interactive outbox example.

4 min read Updated October 13, 2026 Last verified October 13, 2026
ANSWER

Put changes that must commit or roll back together in the same supported database transaction. That boundary does not include an ordinary HTTP call. Record required delivery work locally, then handle remote retries and effects under a separate contract.

IN PLAIN TERMS

An order and its stock reservation may need to be saved together. A courier's booking is different: undoing your database changes does not cancel a booking the courier already accepted. Save the intention to contact it, then track that work separately.

In this illustrative example, placing an order writes three local records: the order, a stock reservation and a dispatch request. The chosen business rule requires all three or none. If each repository commits independently, failure in the third call can leave the first two saved. Moving the calls into the same supported transaction changes that outcome.

The boundary decides what fails together#

Compare the same three writes under two policies. The snippets are pseudocode: the repositories and unit of work are application-specific, not a runnable framework API.

three independent local transactions — pseudocode
# Illustrative pseudocode: every repository call commits independently.
# All three operations write local database records, not remote bookings.
def place_order(cmd):
    order = orders.insert(cmd)                   # BEGIN ... COMMIT
    inventory.reserve(order.lines)              # BEGIN ... COMMIT
    dispatch.book(order.customer, order.lines)   # BEGIN ... raises

# The order and reservation remain; no local dispatch record was saved.

Each successful commit is already outside the next call’s rollback scope. No exception in the third repository can automatically undo the first repository’s earlier commit.

one shared local transaction — pseudocode
# Illustrative pseudocode: repositories share one database transaction.
# dispatch.book writes a LOCAL dispatch record; it makes no HTTP call.
def place_order(cmd):
    with unit_of_work() as uow:                  # BEGIN
        order = uow.orders.insert(cmd)
        uow.inventory.reserve(order.lines)
        uow.dispatch.book(order.customer, order.lines)
    # COMMIT together, or roll back this transaction's database writes.

# The unit of work owns the boundary; repositories do not commit separately.

The second version works only because every repository participates in the same database transaction and the unit of work rolls back on failure. Passing the same object name is not enough if a repository opens another connection and commits independently. A remote courier booking is not made atomic by placing it inside this block.

Find the actual commit and rollback paths before relying on the shape of the code. SQLAlchemy 2.0’s transaction documentation distinguishes a session’s tracked transaction from the connection-level transactions it uses. Its Session.begin() context commits on normal completion and rolls back on an exception. The default autobegin behaviour can establish a transaction without an explicit call to begin.

Atomicity is also not the whole concurrency story. Two transactions can both read a condition before either changes it. Constraints, locks, conditional updates or a suitable isolation level must protect the actual rule. Merely enlarging a transaction does not prove that a read-then-write decision is safe.

Relate the boundary to the business rule#

An aggregate is a modelling boundary for objects whose rules are managed together. It helps explain which changes should remain consistent; it is not a database engine’s limit on how many objects or tables one transaction can update. Do not turn a modelling preference into a universal claim that every transaction must touch exactly one aggregate.

Start with the failure you need to prevent. In the example, an order without its required reservation is invalid, so the design must prevent or explicitly reconcile that state. A reporting view that is briefly behind may be acceptable, so it can have a separate update boundary. Document the accepted intermediate state rather than assuming every disagreement is a defect.

Making the boundary too broad can make unrelated requests compete for the same resources. Making it too narrow can leave part of a required change committed. The useful question is which state is allowed after a failure and which concurrent change could invalidate the decision. Then choose the transaction and coordination mechanism that enforces that rule.

The call inside the transaction#

Now replace the local dispatch write with an HTTP booking. A handler writes an order, calls the courier and commits after the reply. Two different problems appear.

First, waiting for the courier can keep database resources held longer. For example, PostgreSQL 18’s locking documentation describes row locks normally lasting until the transaction ends, with qualifications such as savepoint rollback. A slow remote call can therefore extend contention for affected rows. This is not a universal lock duration or a claim that every database read blocks writers.

Second, the booking can succeed before the local commit fails. Rolling back the database does not cancel that external action. A timeout is ambiguous too: it does not establish that the courier did nothing. Retrying safely requires a way to recognise the same request or reconcile its outcome.

The transactional outbox pattern changes which operations must commit together. Save the order and a pending message in one local transaction. A separate worker publishes the message after commit. The interactive example below uses a broker notification rather than a courier API so that the local state, publication and consumer effect stay visible as separate steps.

Crash between saving and sending

Illustrative example: order O7 should produce notification E42. Predict which records survive a crash and whether a repeated message creates another business effect.

Failure scenario

Producer: Save the order and pending E42 together in one local transaction. Publish only after commit. Crash: After the broker accepts E42, before the relay records it as sent.

Order in database
Absent
Outbox record
absent
Published copies of E42
0
Committed consumer effects
0
  1. The order and pending E42 commit together.
  2. E42 is published and processed, but the relay crashes before recording sent.
  3. Restart finds E42 pending and publishes it again.
  4. Without a transactional processed-id check there are two effects; with it there is one.

The outbox preserves delivery intent, not one-time execution. Enable JavaScript to compare the policies on the same failure.

One message id, one producer transaction, one relay and one crash. Restart retries only persisted outbox work, not an entire HTTP request. Each accepted publish is immediately consumed here; broker delay, retention, poison messages and concurrent workers are omitted. The consumer id and effect share a separate local transaction. External emails or payments need their own retry contract.

Compare a crash after commit with a crash after publishing. A pending record lets a restarted worker find unfinished delivery work. It does not prove that a previous publish failed: a crash before the sent marker can cause a repeat. The outbox pattern’s documented failure window is why the receiver must handle duplicates where required. The linked guide includes a runnable SQLite example; this page’s repository snippets remain pseudocode.

An event’s meaning supplies no delivery guarantee. The next architecture topic, event delivery and consumer obligations, separates the transport contract from the reported occurrence. Idempotency keys and safe retries then explains repeated effects. Keeping the full business-event history to reconstruct state is yet another decision, covered by event sourcing, not a requirement for using an outbox.

IF YOU REMEMBER ONE THING

A local transaction protects participating database changes. A required remote action needs its own delivery and retry design. Check both boundaries against a concrete failure instead of treating one large code block as one atomic operation.

Questions people also ask

5 QUESTIONS
Should one HTTP request be one transaction?

Not automatically. Choose the boundary from the changes that must agree, not from the transport. A request can contain independent work, and a read-only request may still need a transaction and an appropriate isolation level to obtain the required view.

Can a transaction span two databases?

Some supported combinations use distributed transaction coordination, such as two-phase commit. An ordinary local commit or a shared ORM session does not make two databases atomic by itself. Check the actual participating systems and their recovery contract.

Why can a long transaction cause waiting?

It can retain locks or other resources that competing work needs. The exact behaviour depends on the database, statements and isolation level. A remote dependency inside that boundary can extend resource holding when it responds slowly.

Where does the transaction belong in a background job?

Around the job's consistency unit. Per-record or small-batch commits limit rollback scope when records are independent. A rule requiring the entire batch to agree needs a different design; do not split it merely to make transactions shorter.

Does my ORM open a transaction I did not ask for?

It may. With its default autobegin behaviour, SQLAlchemy 2.0 starts tracking a transaction as operations require one. Explicitly manage commit, rollback and close rather than assuming the request or session lifetime supplies the boundary you intended.

The library Browse all articles