Unit of work¶
If you have a transactional boundary (job, command, message, request), model it as a scope.
Typical setup¶
Session/transaction:
Lifetime.SCOPEDRepositories/services:
Lifetime.TRANSIENT(they pull the current session from the scope)
Example (runnable)¶
The example below shares one request-scoped session across transient services
and repositories. Register the consumers with scope=Scope.REQUEST too.
Their default app scope cannot depend on a request-scoped session.
The in-memory session commits pending records when the request succeeds. It discards them when a command raises an error. Both paths close the session. The original error leaves the scope and is caught outside it.
A nested Scope.ACTION shares the request session. Leaving the action keeps
the transaction open until the owning request exits. A later request gets a
new session. Use a deeper scope for nested work; a request cannot contain
another request scope.
This session is a teaching example. For a real database, use its transaction context manager and its commit, rollback and close operations.
uv run python examples/ex_04_scopes_and_cleanup/05_unit_of_work.py
"""Share one transaction across transient services and repositories.
Commit on success, roll back on error, and close at the request boundary.
This in-memory session shows the wiring without a database dependency.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from types import TracebackType
from diwire import Container, Lifetime, Scope
@dataclass
class Store:
records: list[str] = field(default_factory=list)
events: list[str] = field(default_factory=list)
class Session:
def __init__(self, store: Store) -> None:
self.store = store
self.pending: list[str] = []
self.closed = False
# Keep this Python 3.10 example free of a typing-extensions runtime dependency.
def __enter__(self) -> Session: # noqa: PYI034
self.store.events.append("open")
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
try:
if exc_type is None:
self.store.records.extend(self.pending)
self.store.events.append("commit")
else:
self.store.events.append("rollback")
self.pending.clear()
finally:
self.closed = True
self.store.events.append("close")
@dataclass
class Repository:
session: Session
def save(self, record: str) -> None:
self.session.pending.append(record)
@dataclass
class Service:
session: Session
repository: Repository
def execute(self, record: str) -> None:
self.repository.save(record)
class CommandError(Exception):
pass
def main() -> None:
store = Store()
container = Container()
container.add_instance(store)
container.add_context_manager(Session, scope=Scope.REQUEST, lifetime=Lifetime.SCOPED)
container.add(Repository, scope=Scope.REQUEST, lifetime=Lifetime.TRANSIENT)
container.add(Service, scope=Scope.REQUEST, lifetime=Lifetime.TRANSIENT)
with container.enter_scope(Scope.REQUEST) as request:
service = request.resolve(Service)
other = request.resolve(Service)
transient_consumers = service is not other and service.repository is not other.repository
shared_session = service.session is service.repository.session is other.session
service.execute("accepted")
pending_until_exit = not store.records
with request.enter_scope(Scope.ACTION) as action:
nested = action.resolve(Service)
nested_shares_session = nested is not service and nested.session is service.session
action_keeps_transaction_open = not service.session.closed and store.events == ["open"]
committed_and_closed = store.records == ["accepted"] and service.session.closed
original_error = CommandError("Command failed")
error_propagated = False
try:
with container.enter_scope(Scope.REQUEST) as request:
failed = request.resolve(Service)
fresh_session = failed.session is not service.session
failed.execute("rejected")
raise original_error
except CommandError as error:
error_propagated = error is original_error
rolled_back_and_closed = store.records == ["accepted"] and failed.session.closed
lifecycle = store.events == ["open", "commit", "close", "open", "rollback", "close"]
print(f"transient_consumers={transient_consumers}") # => transient_consumers=True
print(f"shared_session={shared_session}") # => shared_session=True
print(f"pending_until_exit={pending_until_exit}") # => pending_until_exit=True
print(f"nested_shares_session={nested_shares_session}") # => nested_shares_session=True
print(
f"action_keeps_transaction_open={action_keeps_transaction_open}"
) # => action_keeps_transaction_open=True
print(f"committed_and_closed={committed_and_closed}") # => committed_and_closed=True
print(f"fresh_session={fresh_session}") # => fresh_session=True
print(f"error_propagated={error_propagated}") # => error_propagated=True
print(f"rolled_back_and_closed={rolled_back_and_closed}") # => rolled_back_and_closed=True
print(f"lifecycle={lifecycle}") # => lifecycle=True
if __name__ == "__main__":
main()
See Scopes & cleanup and Resources (cleanup) for other cleanup patterns.