Component Reference
All components are exported from the package root (seedwork), except the testing layer, which is exported from seedwork.testing and is for use in consumer tests only — never import it from production code.
Domain layer
Entity[TId]
- Role: Base class for DDD entities. Identity over attributes — two entities are equal when they share the same
idof the same concrete class, regardless of other fields. - Usage: Subclass as
@dataclass(frozen=True, eq=False, kw_only=True)and declareidvia inheritance.Entityis anABCwith an abstractvalidate() -> None— every subclass must override it (returnNone/passif there are no extra invariants).Entity.__post_init__raisesNullEntityIdErrorifidisNone, then callsself.validate(). Do not override__post_init__in a subclass — it would shadow the base implementation and silently skip both the null-idguard and thevalidate()dispatch unless you remember to callsuper().__post_init__(). Put invariant checks invalidate()instead. - Key methods:
__eq__compares byidwhen both objects are the same concrete class.__hash__is based onid._evolve(**changes) -> Selfreturns a new instance with the given fields replaced.
AggregateRoot[TId]
- Role: Root of an aggregate. Single entry point for state changes. Accumulates domain events without side effects — all behavior methods return new instances.
- Fields:
domain_events: tuple[DomainEvent, ...]— immutable, keyword-only, excluded fromrepr,hash, andeq. Defaults to(). - Key methods:
_evolve(**changes) -> Self— inherited fromEntity; produces a new instance with updated fields._record(*events) -> Self— returns a new instance with the given events appended todomain_events. - Usage pattern: Use two factory patterns:
open/createfor new aggregates — construct the instance, then chain._record(EventClass.create(...))to attach the raised event;reconstitutefor loading from persistence — construct with no._record(...)call (those events have already been published). Behavior methods chainself._evolve(**state_change)._record(EventClass.create(...))and return the new instance.DomainEventPublishingRepositoryreadsdomain_eventsand publishes aftersave. Every concreteAggregateRootmust still implementEntity.validate().
ValueObject
- Role: Immutable domain concept defined entirely by its attributes. Subclass as
@dataclass(frozen=True, kw_only=True). Equality and hashing are structural — delegated to the dataclass. - Usage: Declare fields directly on the subclass. Override
validate() -> Nonefor invariant checks —ValueObject.__post_init__calls it automatically, and the default implementation is a no-op, so overriding is optional (unlikeEntity, which makes it abstract). Raise aDomainErrorsubclass on invalid input (co-located in the same file). All fields are keyword-only.
DomainEvent / BaseDomainEvent[TPayload]
DomainEvent— Protocol defining the structural interface for domain events:id: str,occurred_at: datetimeandaggregate_id: str.BaseDomainEvent[TPayload]— frozen, keyword-only dataclass. Declarespayload: TPayloadandaggregate_id: str(both required, no defaults), thenid: str(defaults to a UUID) andoccurred_at: datetime(defaults to UTC now).- Pattern: define a frozen payload dataclass, then a frozen event extending
BaseDomainEvent[Payload]with acreate()classmethod that takes plain data and builds the payload internally. Name events in past tense. Keep payload fields primitive (serializable).
@dataclass(frozen=True, kw_only=True)
class MoneyDepositedPayload:
amount: float
currency: str
@dataclass(frozen=True)
class MoneyDeposited(BaseDomainEvent[MoneyDepositedPayload]):
@classmethod
def create(cls, amount: float, currency: str, aggregate_id: str) -> "MoneyDeposited":
return cls(
payload=MoneyDepositedPayload(amount=amount, currency=currency),
aggregate_id=aggregate_id,
)
Repository[TId, TAggregate]
- Methods:
find_by_id(entity_id: TId) -> TAggregate | None,save(aggregate: TAggregate) -> None,delete_by_id(entity_id: TId) -> None. All areasync. - Define a typed sub-interface in the domain layer; implement in infrastructure.
UnitOfWork
- Protocol (structural — no inheritance required). Implementations must provide
__aenter__(self) -> Selfand__aexit__(self, exc_type, exc_val, exc_tb) -> None.__aexit__should commit whenexc_type is Noneand roll back otherwise —TransactionalCommandBusrelies on this contract. If the commit itself fails, the exception propagates to the caller.
DomainError
- Base
Exceptionsubclass. Constructor(message: str, code: str). Exposesself.code. Always subclass with a named class —DomainErroritself is not meant to be raised directly.
Application layer
Result / ResultError
Result.ok()/Result.failed(errors: Sequence[ResultError])— both class methods..errors: tuple[ResultError, ...](immutable).- Check with the
is_okandis_failedproperties — no parentheses:if result.is_failed: ... - Do not write
if result.ok:—okis the class method that builds a success, so the expression is always truthy and every failure reads as success.Resulthas nookinstance attribute at all; accessingresult.okreturns the bound classmethod object itself, which is always truthy. - Use for expected domain failures at the application boundary; let infrastructure exceptions propagate.
Command / CommandBus / CommandHandler[TCommand]
Command— frozen dataclass base. Subclass as@dataclass(frozen=True, kw_only=True)and declare fields directly.CommandHandler[TCommand]— Protocol.handle(self, command: TCommand) -> None(async). The method must be namedhandle— buses call it structurally, so a differently named method fails silently at dispatch time rather than at class definition.CommandBus— Protocol.dispatch(self, command: Command) -> Result(async).
Query[TResult] / QueryBus / QueryHandler[TQuery, TResult]
Query[TResult]— generic frozen dataclass base. Subclass as@dataclass(frozen=True, kw_only=True)and declare the result type as a type parameter:class MyQuery(Query[MyResponse]).QueryHandler[TQuery, TResult]— Protocol.handle(self, query: TQuery) -> TResult | None(async). ReturnNoneto signal absence.QueryBus— Protocol.ask(self, query: Query[TResult]) -> TResult | None(async). The return type is inferred from the query's type parameter — noAny, no cast at the call site.
DomainEventBus family
DomainEventBusPublisher— Protocol.publish(self, events: Sequence[DomainEvent]) -> None(async). Accepts any sequence — tuples fromaggregate.domain_eventsare passed directly.DomainEventHandler[TEvent]— Protocol.handle(self, event: TEvent) -> None(async).DomainEventBusSubscriber— Protocol.subscribe(self, event_type: type[TEvent], handler: DomainEventHandler[TEvent]) -> None.DomainEventBus— Protocol combining both, plusdispatch() -> None(async) anddiscard() -> None.DeferredDomainEventBus(infrastructure layer) is the concrete implementation.- Do not inject a
DomainEventBusPublisherinto command handlers — wrap the repository withDomainEventPublishingRepositoryinstead.
IntegrationEvent family
BaseIntegrationEvent[TPayload]— frozen, keyword-only dataclass. Declarestype: str,version: str,aggregate_id: str,payload: TPayload,correlation_id: str(all required), thenid(UUID default),occurred_at(UTC now default),causation_id: str | None = None,metadata: dict[str, str] | None = None.IntegrationEvent[TPayload]— Protocol mirroring the same structural shape, for adapters that don't want to inheritBaseIntegrationEvent.IntegrationEventPublisher— Protocol.publish(self, events: Sequence[IntegrationEvent[Any]]) -> None(async).IntegrationEventHandler[TIntegrationEvent]— Protocol.handle(self, event: TIntegrationEvent) -> None(async).- Use integration events to notify other bounded contexts; keep them versioned (
version) and traceable (correlation_id/causation_id) since, unlike domain events, they cross process/service boundaries.
BackgroundTask family
BackgroundTask[TPayload]— Protocol:id,type,payload: TPayload,correlation_id,causation_id: str | None,metadata: dict[str, str] | None.BaseBackgroundTask[TPayload]— frozen, keyword-only dataclass implementation of the same shape.type,payload,correlation_idare required;iddefaults to a UUID;causation_id/metadatadefault toNone.TaskScheduler— Protocol.schedule(self, task: BackgroundTask[Any]) -> None(async).TaskHandler[TTask]— Protocol.handle(self, task: TTask) -> None(async).- Use for fire-and-forget side effects (e.g. sending an email) triggered from a domain event handler, dispatched through a
TaskSchedulerrather than performed inline.
ValidationErrorDetail / ValidationErrors
ValidationErrorDetail— frozen, keyword-only dataclass:code: str,message: str.ValidationErrors—Exceptionsubclass. Constructor(errors: list[ValidationErrorDetail]). Exposesself.errors. Use for input-shape validation at the application boundary (e.g. a command's fields), distinct fromDomainError, which represents a broken domain invariant.
Infrastructure layer
RegistryCommandBus
- Routes commands to handlers via in-process registry keyed by command class.
register(command_type, handler),dispatch(command) -> Result.- Catches
DomainErrorand converts toResult.failed. All other exceptions propagate.
bus = RegistryCommandBus()
bus.register(OpenAccountCommand, OpenAccountHandler(repo))
result = await bus.dispatch(OpenAccountCommand(account_id="acc-1", initial_balance=100.0))
result.is_ok # True
# DomainError → Result.failed
result = await bus.dispatch(...) # handler raises InsufficientFundsError
result.is_failed # True
result.errors[0].code # "INSUFFICIENT_FUNDS"
RegistryQueryBus
- Same registry pattern for queries.
register(query_type, handler),ask(query) -> TResult | None. The bus is generically typed, so the return type matches the registered query handler result type. - Raises
KeyErrorwhen no handler is registered for the query type.
bus = RegistryQueryBus()
bus.register(GetBalanceQuery, GetBalanceHandler(read_repo))
balance = await bus.ask(GetBalanceQuery(account_id="acc-1"))
# balance: BalanceResponse | None
TransactionalCommandBus
- Decorator. Wraps dispatch in the
UnitOfWorkcontext manager (async with unit_of_work) and commits only when the command succeeds: Result.ok()→ commit (__aexit__receives no exception). If the commit raises, the exception propagates.Result.failed()→ rollback (__aexit__receives an exception); the same failedResultis returned, not raised. Writes made before the domain rejection are never persisted.- Exception from the inner bus → rollback, then re-raise.
bus = TransactionalCommandBus(inner=registry_bus, unit_of_work=uow)
# Every dispatch runs inside: async with uow: inner.dispatch(command)
# A failed Result rolls the unit of work back and is returned to the caller.
DomainEventCoordinatorCommandBus
- Decorator. Pairs a
CommandBuswith aDomainEventBus(typicallyDeferredDomainEventBus): after a successful dispatch (result.is_ok) it callsevent_bus.dispatch(), delivering every event deferred during the command; on failure or an exception it callsevent_bus.discard()instead — a rolled-back command must never leak events to subscribers. An exception is re-raised after discarding.
bus = DomainEventCoordinatorCommandBus(inner=registry_bus, event_bus=deferred_bus)
DeferredDomainEventBus
- Concrete
DomainEventBusimplementation.subscribe(event_type, handler)registers a handler;publish(events)buffers events per-request (backed by aContextVar, so concurrent requests don't cross-contaminate) instead of dispatching immediately;dispatch()delivers all buffered events to their subscribed handlers and clears the buffer;discard()clears the buffer without dispatching. - Typically wrapped by
DomainEventPublishingRepository(as theevent_busit publishes into) and paired withDomainEventCoordinatorCommandBus(orCommandBusBuilder.with_domain_event_coordination) so events only reach handlers once the whole command has committed.
DomainEventPublishingRepository[TId, TAggregate]
- Decorator. Reads
aggregate.domain_eventsand callsevent_bus.publish(aggregate.domain_events)after everysave(only when there are events to publish).delete_by_idandfind_by_iddelegate without side effects. Constructor:(inner: Repository[TId, TAggregate], event_bus: DomainEventBusPublisher)— both positional or keyword, but the keyword name isevent_bus, notpublisher.
repo = DomainEventPublishingRepository(inner=BankAccountRepositoryImpl(), event_bus=my_event_bus)
account = BankAccount.open(BankAccountId("acc-1"), Money(amount=100.0, currency="EUR"))
await repo.save(account)
# inner_repo.save is called first, then event_bus.publish(account.domain_events)
CommandBusBuilder
- Constructor:
(registry: RegistryCommandBus)— the registry is the innermost bus in the stack, not created implicitly. .register(command_type, handler)— wire handler on the underlying registry (last registration wins)..with_transaction(unit_of_work)— addTransactionalCommandBus..with_domain_event_coordination(event_bus)— addDomainEventCoordinatorCommandBus..use(middleware: Callable[[CommandBus], CommandBus])— add custom middleware..build() -> CommandBus— return assembled bus.- Declaration order = stack order; first declared = outermost.
bus = (
CommandBusBuilder(RegistryCommandBus())
.register(OpenAccountCommand, OpenAccountHandler(repo))
.register(DepositMoneyCommand, DepositMoneyHandler(repo))
.with_transaction(uow)
.build()
)
result = await bus.dispatch(DepositMoneyCommand(account_id="acc-1", amount=50.0, currency="EUR"))
QueryBusBuilder
- Constructor:
(registry: RegistryQueryBus)— same rationale asCommandBusBuilder. .register(query_type, handler)— wire handler..use(middleware: Callable[[QueryBus], QueryBus])— add custom middleware..build() -> QueryBus— return assembled bus.
bus = (
QueryBusBuilder(RegistryQueryBus())
.register(GetBalanceQuery, GetBalanceHandler(read_repo))
.build()
)
balance = await bus.ask(GetBalanceQuery(account_id="acc-1"))
Outbox family
Implements the transactional outbox pattern so integration events and background tasks are persisted in the same transaction as the aggregate change, then delivered by a separate relay process — avoiding dual-write inconsistency between the domain change and the side effect.
IntegrationEventOutboxRecord— frozen, keyword-only dataclass:id,event: IntegrationEvent[Any],status: OutboxStatus,attempts: int,created_at: datetime,last_error: str | None,published_at: datetime | None.OutboxStatus—Literal["pending", "published", "failed"].IntegrationEventOutboxRepository— Protocol:save(event),find_pending(limit=100) -> Sequence[IntegrationEventOutboxRecord],mark_as_published(id),mark_as_failed(id, error). Implement against your persistence layer; write it in the same transaction as the aggregate save.OutboxIntegrationEventPublisher—IntegrationEventPublisherimplementation that writes to anIntegrationEventOutboxRepositoryinstead of publishing directly — pair it with a separate relay/worker that readsfind_pending, delivers, then callsmark_as_published/mark_as_failed.TaskOutboxRecord/TaskOutboxStatus(Literal["pending", "delivered", "failed"]) /TaskOutboxRepository/OutboxTaskScheduler— the same pattern forBackgroundTaskscheduling instead of integration events.
publisher = OutboxIntegrationEventPublisher(repository=my_outbox_repository)
# publisher.publish(events) persists each event as "pending" instead of delivering it directly
Testing layer
Exported from seedwork.testing, not the top-level seedwork package. For use in consumer tests only — never import it from production code. Every in-memory implementation has a matching *Spy structural Protocol documenting the shape consumers can assert against: an all/published/scheduled collection plus a reset() method. RepositorySpy, IntegrationEventPublisherSpy, and TaskSchedulerSpy are @runtime_checkable; IntegrationEventOutboxRepositorySpy and TaskOutboxRepositorySpy are plain Protocols (no isinstance() checks against them).
InMemoryRepository[TId, TAggregate] / RepositorySpy
- Generic in-memory
Repositoryimplementation backed by a plaindict. Intended for use in tests and as a starting point for proof-of-concept implementations. - Satisfies the
Repository[TId, TAggregate]Protocol structurally — no inheritance declaration needed. - All three
Repositorymethods (find_by_id,save,delete_by_id) areasyncand match the contract exactly. Addsall(aSequence[TAggregate]snapshot of the store) andreset().
repo: InMemoryRepository[BankAccountId, BankAccount] = InMemoryRepository()
await repo.save(account)
found = await repo.find_by_id(BankAccountId("acc-1"))
InMemoryIntegrationEventPublisher / IntegrationEventPublisherSpy
- In-memory
IntegrationEventPublisher.publish(events)appends to an internal list;publishedexposes aSequence[IntegrationEvent[Any]]snapshot;reset()clears it.
InMemoryTaskScheduler / TaskSchedulerSpy
- In-memory
TaskScheduler.schedule(task)appends to an internal list (scheduledexposes the snapshot).register(task_type, handler)wires aTaskHandlerper task type;execute_scheduled()drains the queue, invoking the registered handler for each task'stype(silently skipping tasks with no registered handler).reset()clears the queue.
InMemoryIntegrationEventOutboxRepository / IntegrationEventOutboxRepositorySpy
- In-memory
IntegrationEventOutboxRepository.savestores a new"pending"IntegrationEventOutboxRecord;find_pending/mark_as_published/mark_as_failedimplement the outbox contract in memory.allexposes every record regardless of status;reset()clears the store.
InMemoryTaskOutboxRepository / TaskOutboxRepositorySpy
- Same pattern as
InMemoryIntegrationEventOutboxRepository, forTaskOutboxRepository/TaskOutboxRecord/TaskOutboxStatus.