Transactions
When several writes must succeed or fail as a unit, wrap them in a transaction scope. Forze owns the boundary — begin on entry, commit on a clean exit, roll back on an exception — while the adapter does the real database work.
A transaction scope¶
Open a scope by route, the same logical name the deps wiring registered
under tx={...}. Everything inside commits together:
async with ctx.tx_ctx.scope("orders"):
order = await ctx.document.command(order_spec).create(cmd)
await ctx.counter(order_counter).incr()
# committed here; an exception inside would have rolled it all back
The route must be registered for transactions when you wire the module —
PostgresDepsModule(client=pg, ..., tx={"orders"}) — otherwise the scope can't
resolve a transaction manager.
What commits together¶
A scope has a scope key — the kind of transaction, such as a database versus a cache. A port joins the active transaction only if its scope key matches; a port of a different kind runs outside it.
Atomicity is bounded to one manager
A transaction coordinates only the operations that share its scope key and client. Two Postgres writes against the same connection commit atomically; a Postgres write and a Redis write do not. When you need consistency across systems, you don't reach for a bigger transaction — you stage the cross-system effect and apply it after commit (see below and Events & sagas).
Nesting¶
Scopes nest naturally. A nested scope of the same kind joins the outer transaction rather than starting a new one — with a savepoint where the backend supports it, so an inner failure can roll back without losing the outer work:
async with ctx.tx_ctx.scope("orders"):
await ctx.document.command(order_spec).create(cmd)
async with ctx.tx_ctx.scope("orders"): # joins the same transaction
await ctx.document.command(line_spec).create(line_cmd)
A nested scope whose kind doesn't match the active transaction is a programming error and is rejected immediately — you can't open a cache transaction inside a database one and expect them to commit together.
Read-only transactions¶
QUERY operations open their scope read_only=True, so the backend begins a
read-only transaction where it supports one (Postgres BEGIN … READ ONLY) and
rejects accidental writes. You rarely set this by hand — the operation kind does
it for you.
After the commit¶
Some work must happen only if the transaction commits — publishing an event, sending a notification, enqueuing a job. Doing it inside the scope risks acting on a change that later rolls back. Defer it instead:
await ctx.tx_ctx.run_or_defer(send_confirmation)
Inside a transaction, the callback is queued and runs after the root scope commits successfully. Outside any transaction, it runs immediately. This single mechanism is the foundation of the transactional outbox — covered next.
Deferred work is also cancellation-protected: a client disconnect or a deadline expiring between the commit and the deferred callbacks can't skip them — they run to completion, then the cancellation re-raises. A committed transaction is never left half-announced.
Strict transactions under mock¶
By default, the mock plane's transaction manager is a no-op: a write inside a transaction that later rolls back still persists, so a "forgot to run it in the same transaction" bug is invisible in tests. Opt into real rollback semantics when wiring:
from forze_mock import MockDepsModule
module = MockDepsModule(strict_tx=True)
Strict mode rolls back exactly what a database transaction would:
- Rolls back — documents, outbox rows, inbox marks, and the document-backed identity stores. A handler that stages an outbox event and then fails leaves no rows behind, same as Postgres.
- Survives rollback, on purpose — queues, streams, storage blobs, caches, counters, idempotency keys, locks, search and analytics state. Those backends are not transactional in production; rolling them back would make the mock less faithful, hiding the very cross-system consistency gaps the outbox pattern exists to close.
Nested scopes behave as savepoints — an inner rollback reverts only the inner
writes. QUERY operations open their root read_only=True, and strict mode
enforces it: a write to a participating store raises a precondition error with
code read_only_tx, mirroring Postgres BEGIN … READ ONLY.
Strict roots serialize, and Python objects don't roll back
Rollback restores a global snapshot of the shared mock state, so concurrent
root transactions on one MockState are serialized (real databases
serialize conflicting writers anyway). And only mock stores are restored —
in-process side effects outside them, like a handler mutating a Python
object it captured, cannot be rolled back.