Domain Model¶
PyUoW ships a small but opinionated set of entity primitives plus a Batch collector and a Model base that emits events on creation and deletion.
Entity hierarchy¶
Entity[ID]
├── AuditedEntity[ID] -- created_date, updated_date
├── SoftDeletableEntity[ID] -- deleted_date
└── VersionedEntity[ID] -- version
│
└── (composed via mixins in your own subclasses or via Model)
All four are @dataclass(frozen=True). They're imported from pyuow.entity:
from dataclasses import dataclass
from uuid import UUID, uuid4
import typing as t
from pyuow.entity import AuditedEntity, Entity, SoftDeletableEntity, VersionedEntity, Version
UserId = t.NewType("UserId", UUID)
@dataclass(frozen=True)
class User(AuditedEntity[UserId], VersionedEntity[UserId]):
name: str
user = User(id=UserId(uuid4()), name="Alice")
# created_date and updated_date are auto-filled to now (UTC, naive)
# version defaults to Version(0)
Version¶
Version is an int subclass that refuses negatives and exposes .next():
from pyuow.entity import Version
v = Version(3)
v.next() # Version(4)
Version(-1) # raises ValueError
Model — event-emitting entity¶
Model[ID] extends AuditedEntity, SoftDeletableEntity, and VersionedEntity. Every fresh instance auto-generates an id (via _generate_id()) and emits a ModelCreatedEvent. Calling .delete() emits a ModelDeletedEvent.
from dataclasses import dataclass, replace
import datetime
import typing as t
from uuid import UUID, uuid4
from pyuow.domain import Model
from pyuow.domain.event import (
ModelCreatedEvent,
ModelDeletedEvent,
ModelEvent,
)
OrderId = t.NewType("OrderId", UUID)
@dataclass(frozen=True)
class OrderCreated(ModelCreatedEvent[OrderId]):
sku: str
@dataclass(frozen=True)
class OrderDeleted(ModelDeletedEvent[OrderId]):
pass
@dataclass(frozen=True)
class OrderUpdated(ModelEvent[OrderId]):
new_quantity: int
@dataclass(frozen=True)
class Order(Model[OrderId]):
sku: str = ""
quantity: int = 0
def _generate_id(self) -> OrderId:
return OrderId(uuid4())
def _created_event(self, date: datetime.datetime) -> OrderCreated:
return OrderCreated(
id=uuid4(),
model_id=self.id,
sku=self.sku,
created_date=date,
)
def _deleted_event(self, date: datetime.datetime) -> OrderDeleted:
return OrderDeleted(
id=uuid4(), model_id=self.id, deleted_date=date
)
def change_quantity(self, new_quantity: int) -> "Order":
return self.update(
event=OrderUpdated(
id=uuid4(), model_id=self.id, new_quantity=new_quantity
),
quantity=new_quantity,
)
Lifecycle¶
# Fresh model -> is_new=True, has a created event
order = Order(sku="WIDGET", quantity=3)
order.is_new # True
order.events() # (OrderCreated(...),)
# Mutate via .update() -> immutable replace + appended event
ordered = order.change_quantity(5)
ordered.events() # (OrderCreated(...), OrderUpdated(new_quantity=5))
# Delete -> sets deleted_date + appends a deleted event
canceled = ordered.delete()
canceled.is_deleted # True
canceled.events() # (... OrderDeleted(...))
events() returns an immutable tuple. Each call returns a fresh tuple over the same event objects (events are frozen, no defensive copy needed).
Batch — collecting changes¶
Batch is a per-flow accumulator. Units call batch.add(entity), batch.update(entity), batch.delete(entity) as they go. The Work Manager flushes the batch at the end of the transaction.
from pyuow.domain import Batch
batch = Batch()
batch.add(Order(sku="WIDGET", quantity=3)) # ADD change for a new model
batch.update(existing_order) # UPDATE change
batch.delete(canceled_order) # DELETE change
batch.changes() # dict keyed by entity.id, values are Change records
batch.events() # tuple of all events from all Models in the batch
Batch rules¶
The batch enforces operation ordering invariants. Violations raise specific exceptions from pyuow.domain:
| Violation | Exception |
|---|---|
.add() on a Model that's already persisted |
CannotAddExistingEntityError |
.update() on a fresh Model |
CannotUpdateNewEntityError |
.delete() on a fresh Model |
CannotDeleteNewEntityError |
Any mutation after .shut() |
BatchShutError |
| Same entity id added twice | DuplicateEntityInBatchError |
All five inherit from BatchError, so catching the parent covers all cases.
Batch and Domain Context¶
BaseDomainContext is a BaseContext with a batch: Batch field built in. Combine it with DomainTransactionalWorkManager and the batch flushes automatically.
from dataclasses import dataclass
from pyuow.context.domain import BaseDomainContext
@dataclass(frozen=True)
class OrderParams(BaseParams):
sku: str
@dataclass(frozen=True)
class OrderCtx(BaseDomainContext[OrderParams]):
params: OrderParams
Inside a RunUnit, append to the batch:
class CreateOrder(RunUnit[OrderCtx, OrderResult]):
def run(self, ctx: OrderCtx) -> None:
ctx.batch.add(Order(sku=ctx.params.sku, quantity=1))
The manager calls your batch_handler after the flow finishes — see Work Manager.
Events¶
ModelEvent¶
ModelEvent[ID] is the base class for domain events emitted by a Model. Required fields:
id: UUID— event identitymodel_id: ID— the entity it belongs tooccurred_at: int— nanosecond UTC timestamp (auto-filled frompyuow.clock.nano_timestamp_utc)
from dataclasses import dataclass
from pyuow.domain.event import ModelEvent
@dataclass(frozen=True)
class OrderShipped(ModelEvent[OrderId]):
tracking_number: str
ModelCreatedEvent / ModelDeletedEvent¶
Two ready-made subclasses with created_date / deleted_date fields. Used by Model._created_event() and Model._deleted_event() respectively.
EventHandler¶
pyuow.domain.event.EventHandler is a protocol-style ABC for the consumer side:
import typing as t
from pyuow.domain.event import EventHandler, ModelEvent
class MyEventBus(EventHandler):
def __call__(self, events: t.Sequence[ModelEvent[t.Any]]) -> None:
for event in events:
self._publish(event)
An async version lives at pyuow.domain.aio.event.EventHandler with async def __call__.