Unit of Work
DDD CQRS ES
Every meaningful state change in a Protean application (persisting an aggregate, raising domain events, writing to the outbox) must happen atomically. Either all of it commits, or none of it does. The Unit of Work (UoW) is the mechanism that makes this guarantee.
In most application code you never interact with the UoW directly. Two
decorators automatically wrap each invocation in a UoW: @handle on
command/event handler methods and @use_case on application service methods.
You still need to know how the UoW works, because it decides when your changes
are committed, what happens on failure, and how events reach the outside world.
Who creates the UoW?
You almost never need to create a UoW yourself. Protean's decorators create the UoW for you:
| Decorator | Creates UoW? | Details |
|---|---|---|
@handle on command handler methods |
Yes | Each handler method runs inside its own UoW. |
@handle on event handler methods |
Yes | Each handler method runs inside its own UoW. |
@use_case on application service methods |
Yes | Runs inside a UoW. If one is already active (for example the service is called from a command handler), it joins that one instead of opening its own (see Nested Units of Work). |
repository.add() outside a handler |
Yes | If no UoW is in progress, add() creates a temporary one, commits it immediately after persisting, then discards it. |
Each top-level handler invocation runs in its own UoW, so one handler failure cannot corrupt another handler's changes. A UoW opened inside an already-active one is different: it joins the outer transaction rather than getting its own (see Nested Units of Work).
When the transaction actually opens
A Unit of Work opens no session when it starts. The session, and on a relational
provider the real BEGIN, appears at the first repository access. An
invocation that reaches no repository therefore runs no transaction and holds no
pooled connection.
A handler can therefore call an external system without holding a transaction
or a pooled connection. Put the call in a method that persists nothing, and the
call costs only wall-clock time. When a method calls out after touching a
repository, it holds row locks and a connection for the length of the call.
protean check reports such a method as
HANDLER_PERSISTS_AND_CALLS_OUT.
The check does not read @use_case methods and recognises only some calls as
I/O. A clean report is therefore not proof that no method calls out after
persisting (see what the source checks
cover).
For how to split such a method, and the recipe for a multi-facet sync, see
Calling External Systems from
Handlers.
The guarantee is about the invocation rather than the method body. A handler
carrying idempotent=True reads its processed-message marker before the body
runs. With a DB-backed marker store, that read is a repository access, so the
invocation opens a session before the body.
Manual UoW
For scripts, data migrations, shell sessions, and tests, you can create a UoW
explicitly. The code must run with the domain active, for example inside
with domain.domain_context()::
from protean import UnitOfWork
def confirm_order(order_id):
with UnitOfWork():
repo = domain.repository_for(Order)
order = repo.get(order_id)
order.confirm()
repo.add(order)
# Commit happens automatically when the block exits successfully
You can also use the imperative API:
def confirm_order_step_by_step(order_id):
uow = UnitOfWork()
uow.start()
try:
repo = domain.repository_for(Order)
order = repo.get(order_id)
order.confirm()
repo.add(order)
uow.commit()
except Exception:
uow.rollback()
raise
Note
The context manager form (with UnitOfWork()) is preferred. It commits on
a normal exit and rolls back on an exception, so it never leaves a UoW open
by mistake.
current_uow
The active UoW is accessible anywhere through the current_uow context
variable:
from protean import current_uow
def describe_saving() -> str:
if current_uow and current_uow.in_progress:
# A UoW is active, so changes will be committed when it exits
return "changes wait for the commit"
return "changes are saved at once"
This is a thread-local proxy backed by a context stack. The outermost start()
(or __enter__) pushes its UoW onto the stack, and its commit() or rollback()
pops it off. A nested UoW (one started while another is active) does not push
onto the stack: it joins the outermost UoW, so current_uow keeps pointing at
that one (see Nested Units of Work).
Nested Units of Work
When you start a UnitOfWork while another is already active on the same context, the new UnitOfWork takes part in the outer one and opens no transaction of its own. Protean does not use savepoints, so the nested UoW joins the outermost UoW. It does not push onto the context stack, so every write, read, and event routes to the outermost UoW, and only that UoW commits or rolls back. A nested rollback rolls back the whole transaction.
def save_together(repo, a, b, c):
with UnitOfWork(): # outermost: owns the transaction
repo.add(a)
with UnitOfWork(): # nested: joins the outer, does not commit on its own
repo.add(b)
repo.add(c)
# a, b, and c all commit together when the outermost block exits,
# and all roll back together if anything fails.
Nesting happens most often through composition: a command handler calls an
application service (@use_case) inside its UoW. The application service then
joins the handler's transaction and does not commit independently. That is
intended, so the whole use case stays atomic.
Why joining, not independent inner transactions
Protean does not support independent inner transactions: a nested UoW cannot commit or roll back on its own, and there is no savepoint to partially roll back to. Protean leaves them out on purpose, for three reasons.
Independent inner transactions are a real database tool. A savepoint lets an inner scope roll back on its own; a separate (suspend-and-new) transaction lets an inner scope commit even if the outer later rolls back. Protean's Unit of Work supports neither:
- It is an infrastructure concern, not a domain one: A savepoint or a sub-transaction is a persistence-layer mechanism. The domain-layer Unit of Work exists to keep one aggregate consistent for one use case, and the aggregate is the consistency boundary. Sub-transactions do not belong at that layer.
- Uniformity across adapters: Not every adapter can offer savepoints or sub-transactions: the Memory adapter has no savepoints and Elasticsearch has no transactions at all. Exposing savepoints would make a Unit of Work behave differently per adapter and break the "write once, run on any adapter" promise.
- Simplicity, and it keeps your design honest: There are no savepoint stacks or partial-rollback rules to reason about, because a Unit of Work is one transaction, all-or-nothing. And when you find yourself reaching for an inner transaction, it is usually a signal to re-examine the aggregate boundary rather than paper over it, which keeps the model sustainable as it grows.
Note that Protean does not forbid nesting. Raising an error would break legitimate composition, like an application service called from a command handler. A nested Unit of Work joins the outer one, so you get the correct single-transaction behavior automatically. You cannot express an independent inner commit. When you genuinely need the effect of an inner transaction, model it explicitly:
- for cross-aggregate coordination, raise a domain event and let a separate handler (in its own UoW) react;
- for a durable side-effect that must survive a rollback (an audit record, say), use the outbox or a write outside the UnitOfWork.
If a nested Unit of Work fails, the whole transaction rolls back at commit,
even when your code catches the error. A common case is a handler that calls
domain.process(..., asynchronous=False), catches the exception the inner
handler raised, and continues. When the outer block then ends normally, its
commit rolls everything back and raises that same exception, so the caller sees
the real cause. An explicit nested_uow.rollback() with no exception does the
same and raises TransactionError. To avoid this, let the exception propagate,
or do the work that can fail outside the outer Unit of Work.
See ADR-0027 for the rationale.
What happens during commit
When the UoW commits (either at the end of a with block or via an explicit uow.commit()
call) the following steps execute in order:
-
Gather events: The UoW walks its identity map and collects the domain events raised (via
self.raise_()) on each aggregate thatadd()saved in this transaction. Events raised on an aggregate that was only read are not saved. -
Write outbox messages: Each event is serialized and written to the outbox table as part of the same database transaction. The outbox ensures reliable delivery even if the broker is temporarily unavailable. Events inherit the current processing priority (normal or backfill).
-
Commit database sessions: The UoW commits every open database session. Each provider's session is committed independently. If a commit fails, a
TransactionErroris raised with diagnosticextra_info(original exception, session names, event/message counts). -
Append to event store: After the database commit succeeds, events are appended to the event store for the permanent event log.
-
Publish to broker: Any messages registered during the transaction (via
uow.register_message()) are published to their designated broker. -
Dispatch sync handlers: If
event_processingis set to"sync", the UoW dispatches each event to its registered event handlers immediately. The commit's writes are already saved by then, so a handler's failure,ExpectedVersionErrorincluded, is raised as aTransactionErrorwith the handler's exception as its__cause__. See the 0.18 migration note. -
Clear events: The UoW clears the events gathered in step 1 from their aggregates, so those events are not re-processed. An aggregate that was only read keeps its events on the instance.
Rollback semantics
When an exception is raised inside a UoW block:
-
Context manager form (
with UnitOfWork()): The__exit__method detects the exception, callsrollback(), and re-raises the original exception. No partial state is committed. -
If commit itself fails: The UoW rolls back all sessions and raises a
TransactionErrorwrapping the original exception. -
Rollback scope: Rollback reverses the database session changes. Events that were gathered but not yet committed are discarded. The identity map and message queue are cleared.
from protean import UnitOfWork
from protean.exceptions import ValidationError
def try_to_confirm_order(order_id):
try:
with UnitOfWork():
repo = domain.repository_for(Order)
order = repo.get(order_id)
order.confirm() # May raise ValidationError
repo.add(order)
# If confirm() or add() raises, rollback happens automatically
except ValidationError:
# The UoW has already rolled back, no partial state was committed
...
The identity map
The UoW maintains an identity map: one instance of each aggregate that it has loaded or saved during the current transaction. The identity map serves three purposes:
-
One instance per aggregate: Inside a UoW, a
get()or a query that finds an aggregate already in the map returns that same instance, with any changes made to it since. Two copies of one aggregate would each count from the stored version, so saving the second would fail withExpectedVersionError. The identity map in SQLAlchemy's session and in Hibernate also returns the one instance it already holds. A secondget()does not throw away unsaved changes. To abandon a change, raise an exception so the UoW rolls back. To read the stored state, read the aggregate in a new UoW. -
Event collection: At commit time, the UoW walks the identity map to gather the events raised by the aggregates that
add()saved. An aggregate's events are saved only when it is passed toadd(). Its field changes are saved only throughadd(), so an event saved without it would record a change that never reached the database. For an event-sourced aggregate,add()also raises the aggregate's fact event. Events raised on an aggregate that was only read are not saved at commit, and the UoW logs nothing about them. Removing an aggregate with_dao.delete()does not save its events either.
Call add() after the last change. add() writes the aggregate as it is
at that moment. If a method changes the aggregate after add(), call
add() again. Otherwise the UoW does not write the change, but still saves
the event raised with it at commit. An add() writes nothing for an
aggregate with no pending events and no change since it was read, so the UoW
does not save an event raised after that add().
- Per-provider tracking: Aggregates are grouped by their database provider, so the UoW can commit each provider's session independently.
One transaction, one aggregate
Never enclose updates to multiple aggregates in a single Unit of Work. Aggregates are consistency boundaries. Each transaction should modify at most one aggregate.
Cross-aggregate state changes are coordinated through domain events and eventual consistency:
-
Step 1: A command handler mutates and persists Aggregate A. The UoW commits the changes and dispatches the events raised by Aggregate A.
-
Step 2: An event handler (running in its own UoW) reacts to the event, loads Aggregate B, mutates it, and persists the changes.
With this pattern, each aggregate is always persisted in its own transaction. A failed transaction therefore cannot leave one aggregate partly updated.
Multi-provider sessions
When your domain uses multiple database providers (e.g., PostgreSQL for transactional data, Elasticsearch for search), the UoW manages a separate session for each provider. At commit time, each provider's session is committed independently. This means that a failure in one provider's commit does not roll back another provider's already-committed changes.
Database transaction capabilities
The UoW relies on the underlying database provider's transaction support.
- Full transactions (e.g., PostgreSQL, SQLite): Changes are atomic, commit succeeds entirely or rolls back entirely.
- Simulated transactions (e.g., Memory adapter in tests): The UoW manages the identity map and event collection, but rollback does not undo persisted changes. A debug-level log message notes this limitation.
- No transaction support: The UoW logs a warning and proceeds. Changes are persisted but not guaranteed to be atomic.
Optimistic concurrency
When the event store detects a version conflict during commit (another
transaction modified the same aggregate stream), the UoW raises an
ExpectedVersionError. This is Protean's optimistic concurrency mechanism. The
first writer wins, and subsequent writers must retry with the latest version.
In async handlers (command handlers and event handlers decorated with
@handle), the framework automatically retries ExpectedVersionError
with exponential backoff. Each retry runs in a new UnitOfWork, so the
handler re-reads the aggregate at the latest version. Most version conflicts
in a handler resolve this way without any manual intervention.
A handler that runs inside an outer UnitOfWork does not retry. This happens
when another handler or an application service processes a command
synchronously, or when your code opens a UnitOfWork around
domain.process(). The handler's UnitOfWork joins the outer one, so the
failed attempt has already marked the outer transaction for rollback. The
conflict propagates instead. When the outermost caller is a @handle handler
with version retry on, that handler re-runs the whole chain in a fresh
UnitOfWork. An application service, a process manager or your own
UnitOfWork has no retry, so its caller receives the ExpectedVersionError.
For details, see
Version conflict
auto-retry.
For application services or direct UnitOfWork usage, you must handle
ExpectedVersionError yourself. See
Optimistic Concurrency as a Design Tool
for the three conflict categories and how to respond to each.
Errors during commit
If the database commit fails for reasons other than version conflicts on the
commit's own writes, the UoW raises a TransactionError with diagnostic
information. A version conflict raised by a synchronous event handler also
arrives as a TransactionError, with the ExpectedVersionError as its
__cause__:
from protean.exceptions import TransactionError
def save_order(order):
try:
with UnitOfWork():
domain.repository_for(Order).add(order)
except TransactionError as exc:
# exc.extra_info contains:
# - original_exception: exception class name
# - original_message: error message
# - sessions: list of provider names involved
# - events_count: number of events that were pending
# - messages_count: number of broker messages pending
return exc.extra_info
return None
See also
Related guides:
- Persist Aggregates: Save and update aggregates through repositories.
- Command Handlers: Each handler method runs within an implicit Unit of Work.
- Application Services: Use
@use_casefor automatic Unit of Work management.
Error handling:
- Error Handling: Automatic version conflict retry in async handlers.
- Optimistic Concurrency as a Design Tool: Classify version conflicts by business meaning.