How to use UnitOfWork for atomic transactions
Coordinate multi-repository writes and post-commit domain events within an atomic all-or-nothing boundary.
When multiple services or repositories must modify the database within a single transaction, instantiate a UnitOfWork block to guarantee atomicity.
1. Instantiate UnitOfWork Inside the Service Method
Inject the system EventDispatcher and your repositories into the service constructor. Then, wrap multi-step database mutations inside async with UnitOfWork(...):
# services.py
from zcore import BaseService, UnitOfWork, EventDispatcher
from .models import Order
from .repositories import OrderRepo, InventoryRepo
from .schemas import OrderCreate
class CheckoutService(BaseService[Order]):
def __init__(
self,
repository: OrderRepo,
inventory_repo: InventoryRepo,
dispatcher: EventDispatcher
):
super().__init__(model=Order, repository=repository)
self.inventory_repo = inventory_repo
self.dispatcher = dispatcher
async def process_checkout(self, order_data: OrderCreate, product_id: str, quantity: int):
# 1. Open an isolated transaction boundary using the active session and dispatcher
async with UnitOfWork(self.repository.db, self.dispatcher) as uow:
# Both operations share the exact same database transaction
order = await self.repository.create(order_data)
await self.inventory_repo.decrement_stock(product_id, quantity)
# 2. Buffer domain events to dispatch ONLY after successful commit
uow.register_event("order.completed", {"order_id": str(order.id)})
# If any step fails, BOTH operations roll back and the event is never dispatched.2. Nested & Re-entrant Transactions
In modular applications, domain services frequently invoke one another (e.g. CheckoutService calling an InventoryService). Each service can safely wrap its persistence operations within async with UnitOfWork(...):
- Inner Scopes (
depth > 1): Calls touow.commit()safely executesession.flush()to populate database IDs and apply constraints without committing the physical transaction. - Root Scope (
depth == 0): The outermost context manager boundary executes the final physical database commit. - Deferred Events Buffer: Events registered across all nested levels (
session.info['uow_events']) are accumulated in the shared session buffer and dispatched together only after the root transaction commits. - Chain Rollback: An unhandled error at any depth immediately resets
uow_depth, cancels all pending events, and rolls back the entire transaction chain.
3. Post-Commit Domain Events
UnitOfWork.register_event(event_name, payload) prevents side-effects on transaction failures:
- Events are queued in an internal buffer.
- If the database commit succeeds, all buffered events are dispatched concurrently via
EventDispatcher. - If a database error occurs or an exception is raised, the transaction rolls back and all pending events are discarded immediately.
Automatic Coordination with BaseService:
Entering async with UnitOfWork(...) automatically marks the session as uow_managed = True. Any BaseService calls (such as self.repository.create) inside the block will automatically skip their individual commits and let UnitOfWork finalize the transaction on block exit.
No Manual Commits:
Never call db.commit() manually when working inside a UnitOfWork block. Commit and rollback transitions are orchestrated automatically upon context exit (__aexit__).
How to use pre/post hooks in Services
Execute business logic, compute fields, coordinate side-effects, and handle soft-delete restoration across mutation lifecycles.
How to listen to and dispatch domain events
Subscribe to event channels using @on_event decorators, register listeners in plugins, and dispatch events safely.