API Reference¶
Reference documentation for the public API of eventsource-py: the names exported from
the top-level eventsource package, the modules behind them, the optional backends and
their availability flags, and the shared exception and type-alias vocabulary.
The distribution is named eventsource-py and is imported as eventsource:
Moved and removed top-level modules¶
Six top-level modules moved onto their ring homes (ADR 0030): eventsource.types ->
eventsource.domain.types, eventsource.exceptions -> eventsource.domain.exceptions,
eventsource.protocols -> eventsource.ports.handlers, eventsource.commands ->
eventsource.domain.command, eventsource.sync -> eventsource.adapters.sync, and
eventsource.serialization -> eventsource.adapters.serialization. The old paths are
gone -- importing any of them raises ModuleNotFoundError, with no deprecation shim
and no transition window. eventsource.locks and eventsource.readmodels are gone
the same way (ADR 0030 also removed those two shims ahead of the 0.8.0 schedule ADR
0029 originally set). eventsource.config is deleted outright; it was an empty
placeholder with no importers anywhere in the codebase. Top-level
from eventsource import ... imports are unaffected by any of this; only direct
submodule imports need updating.
__version__ is resolved at import time from installed distribution metadata via
importlib.metadata.version("eventsource-py"), falling back to "0.0.0.dev0" when the
package is used from a source checkout that has not been installed.
The core dependencies are pydantic and sqlalchemy. Everything else — PostgreSQL,
SQLite, Redis, RabbitMQ, Kafka, OpenTelemetry — is an optional extra, and the modules
wrapping those drivers remain importable whether or not the driver is present.
This page is descriptive, not instructional: it states what exists and how it behaves. For a guided introduction start with the tutorials; for task-oriented recipes see the how-to guides.
Scope and Conventions¶
This page documents the top-level barrel — the names bound by
src/eventsource/__init__.py and listed in its __all__ — plus the conventions that
apply uniformly across the reference pages: how optional backends are exposed, and what
shape the public methods take.
Public Import Surface (from eventsource import ...)¶
eventsource.__all__ is the supported public surface. It contains 130 names when the
SQLite extra is absent and 133 when it is present (AIOSQLITE_AVAILABLE, SQLiteEventStore,
and SQLiteOutboxRepository are the only names conditionally appended). The barrel is
deliberately flat —
from eventsource import DomainEvent, AggregateRoot, InMemoryEventStore is the idiomatic
import style, and the module paths underneath (eventsource.domain.event,
eventsource.domain.aggregate, eventsource.adapters.memory) are where those names are
defined rather than where callers are expected to reach.
The exported names fall into these groups:
| Group | Representative names |
|---|---|
| Version metadata | __version__ |
| Type aliases | AggregateId, EventId, TenantId, CorrelationId, CausationId |
| Events and registry | DomainEvent, EventRegistry, default_registry, register_event, get_event_class, get_event_class_or_none, is_event_registered, list_registered_events, EventTypeNotFoundError, DuplicateEventTypeError |
| Event store ports | EventAppender, StreamReader, EventLookup, GlobalEventFeed, CategoryQuery, AggregateStore, FullEventStore, EventEnvelope, AppendResult, StreamReadOptions, FeedReadOptions, CategoryReadOptions, ReadDirection, Position, ExpectedVersion |
| Event store implementations | InMemoryEventStore, PostgreSQLEventStore, plus SQLiteEventStore when available |
| Aggregates | AggregateRoot, DeclarativeAggregate, AggregateRepository, handles |
| Event bus | EventBus, EventHandlerFunc, InMemoryEventBus, and the Redis, RabbitMQ, and Kafka bus classes with their *Config and *Stats types |
| Handler protocols | EventHandler, AsyncEventHandler, SyncEventHandler, FlexibleEventHandler, EventSubscriber, FlexibleEventSubscriber |
| Projections and read models | Projection, CheckpointTrackingProjection, DeclarativeProjection, DatabaseProjection, ReadModelProjection |
| Repository infrastructure | checkpoint, DLQ, and outbox protocols with their in-memory and PostgreSQL implementations and data types |
| Snapshots | Snapshot, SnapshotStore, InMemorySnapshotStore, and the four snapshot exceptions (SnapshotError, SnapshotDeserializationError, SnapshotSchemaVersionError, SnapshotNotFoundError) |
| Multi-tenancy | TenantDomainEvent, tenant_context, tenant_scope, tenant_scope_sync, the context accessors, and the tenant exceptions |
| Sync adapters | SyncEventStoreAdapter |
| Serialization | EventSourceJSONEncoder |
| Exceptions | EventSourceError and its subclasses |
| Availability flags | REDIS_AVAILABLE, KAFKA_AVAILABLE, RABBITMQ_AVAILABLE, and AIOSQLITE_AVAILABLE when the extra is installed |
Several public subsystems ship in the package but are not re-exported at the top
level. They are imported from their own modules: eventsource.testing,
eventsource.application.subscriptions, eventsource.observability, eventsource.application.migration,
eventsource.ports.locks / eventsource.adapters.postgresql.locks,
eventsource.gdpr, and the raw SQL under eventsource.adapters.sql.schemas.
(eventsource.locks no longer exists -- see ADR 0030; import
eventsource.ports.locks / eventsource.adapters.postgresql.locks instead.
eventsource.config no longer exists either -- it was an empty placeholder
with no importers.)
A few names are also narrower at the barrel than in their defining adapter package.
Snapshots are the clearest case: PostgreSQLSnapshotStore (eventsource.adapters.postgresql)
and SQLiteSnapshotStore plus its own SQLiteNotAvailableError
(eventsource.adapters.sqlite) are not re-exported at the top level, though the
top-level AIOSQLITE_AVAILABLE flag does cover the SQLite snapshot store along with the
SQLite event store. Persistent snapshot stores are therefore imported path-only, e.g.
from eventsource.adapters.postgresql import PostgreSQLSnapshotStore.
eventsource.ports.handlers, eventsource.domain.exceptions, and eventsource.domain.types are partly
re-exported: the barrel carries the commonly used names from each, while the modules
themselves remain the canonical definition sites and contain additional members.
eventsource._internal no longer exists as a package; its one module,
BackgroundTaskManager, moved to eventsource.application.background_tasks.
Optional Backends and *_AVAILABLE Flags¶
Every driver beyond pydantic and sqlalchemy is an extra declared in
pyproject.toml: postgresql (asyncpg), sqlite (aiosqlite), redis, rabbitmq
(aio-pika), kafka (aiokafka), kafka-schema-registry (adds confluent-kafka), and
telemetry (opentelemetry-api and -sdk). all pulls in all six backend/telemetry
extras; all-backends is the narrower postgresql,sqlite pair.
Optional drivers are guarded at import time, and the library uses two distinct patterns.
Always importable, fails at construction. The Redis, RabbitMQ, and Kafka bus modules
import their driver inside a try/except ImportError, set a module-level flag, and bind
the driver names to None so the module still imports. The bus classes themselves are
imported unconditionally by the barrel, so RedisEventBus, RabbitMQEventBus, and
KafkaEventBus — and their flags REDIS_AVAILABLE, RABBITMQ_AVAILABLE,
KAFKA_AVAILABLE — are always present in eventsource.__all__ even with no driver
installed. The check moves to __init__, which raises RedisNotAvailableError,
RabbitMQNotAvailableError, or KafkaNotAvailableError. Each is a subclass of
ImportError carrying the install hint for its extra:
from eventsource import REDIS_AVAILABLE, RedisEventBus
if REDIS_AVAILABLE:
bus = RedisEventBus() # otherwise raises RedisNotAvailableError
SQLiteSnapshotStore in eventsource.adapters.sqlite follows this same pattern, with
SQLiteNotAvailableError raised from its constructor.
Conditionally importable, absent when missing. The SQLite store and repositories
follow the other pattern: SQLiteEventStore, SQLiteCheckpointRepository,
SQLiteDLQRepository, and SQLiteOutboxRepository are imported by the barrel inside a
try/except ImportError. When aiosqlite is not installed they are not bound at all,
and from eventsource import SQLiteEventStore raises ImportError. AIOSQLITE_AVAILABLE
is always defined as a module attribute, but it is appended to __all__ — along with the
four SQLite names — only when the import succeeded. That conditional extension accounts
for the 102-versus-107 difference in the export count.
from eventsource import AIOSQLITE_AVAILABLE
if AIOSQLITE_AVAILABLE:
from eventsource import SQLiteEventStore
PostgreSQL has no flag. PostgreSQLEventStore, PostgreSQLSnapshotStore, and the
PostgreSQL repositories are built on SQLAlchemy async and take an AsyncEngine supplied
by the caller, so the asyncpg requirement surfaces where the engine is created
(create_async_engine("postgresql+asyncpg://...")) rather than at import.
Two further flags live outside the barrel. eventsource.observability exposes
OTEL_AVAILABLE for the OpenTelemetry tracing integration, and
eventsource.application.subscriptions exposes OTEL_METRICS_AVAILABLE (re-exported from its
shutdown module, and also available as SHUTDOWN_OTEL_METRICS_AVAILABLE) for the metrics
half; eventsource.application.migration.metrics defines its own OTEL_METRICS_AVAILABLE. When
OpenTelemetry is absent, tracing and metrics degrade to no-ops rather than raising.
The rule to rely on: for the bus backends and the SQLite snapshot store, check the flag rather than guarding the import; for the SQLite store and repositories, check the flag before importing.
Async-First Signatures¶
Everything that performs I/O is async. await is the default calling convention
throughout the reference pages, and a signature shown without async is genuinely
synchronous — a property, a classmethod constructor, an in-process registration call, or
a pure-computation helper.
Three shapes recur across the async surface.
Coroutines for bounded results. EventAppender.append, StreamReader.get_stream_version,
EventLookup.event_exists, and GlobalEventFeed.current_position are async def methods
returning a single value: an AppendResult, an int, a bool, a Position | None. The
same holds for SnapshotStore.save_snapshot, get_snapshot, delete_snapshot,
snapshot_exists, and delete_snapshots_by_type; for AggregateRepository.load,
load_or_create, save, exists, get_version, get_or_raise, create_snapshot, and
await_pending_snapshots; and for Projection.handle and reset.
Async iterators for unbounded reads. StreamReader.read_stream,
GlobalEventFeed.read_all, and CategoryQuery.read_category are declared as
AsyncIterator[EventEnvelope], so large streams are consumed incrementally with
async for rather than materialized in memory:
Each of these three read methods is defined on its own narrow port protocol
(StreamReader, GlobalEventFeed, CategoryQuery) — a backend adapter implements
whichever ports its storage model actually supports.
Sync registration, async delivery. EventBus splits along this line:
publish(events, background=False) is async def, while subscribe, unsubscribe,
subscribe_all, subscribe_to_all_events, and unsubscribe_from_all_events are plain
def — wiring up subscribers mutates in-process state and does not need to be awaited.
Passing background=True to publish makes delivery fire-and-forget, trading
synchronous error propagation for eventual consistency.
Handlers themselves may be either. EventHandler and AsyncEventHandler declare
async def handle; SyncEventHandler declares a plain def handle; and
FlexibleEventHandler / FlexibleEventSubscriber type handle as returning
Awaitable[None] | None, which is what lets the bus and subscription machinery accept
both kinds and adapt at call time.
Synchronous members that appear alongside all of this are not oversights. The frozen
Position/ExpectedVersion dataclasses expose classmethod constructors
(ExpectedVersion.any_(), ExpectedVersion.no_stream(), ExpectedVersion.stream_exists(),
ExpectedVersion.exact(version), Position.from_str/to_str); AggregateRepository
exposes configuration properties (aggregate_type, snapshot_mode,
has_snapshot_support, pending_snapshot_count) and the purely local create_new.
For callers that cannot await at all — Celery tasks, Django management commands, RQ
workers — SyncEventStoreAdapter (eventsource.adapters.sync.adapter) wraps any FullEventStore
and exposes plain-def counterparts of the same port methods (append, read_stream,
get_stream_version, event_exists, read_all, read_category, current_position),
plus a wrapped_store property. See eventsource/sync/adapter.py for exact signatures
before relying on details beyond this summary — a parallel effort keeps docs/api/sync.md
in sync with it in more depth.
from eventsource.adapters.sync import SyncEventStoreAdapter
sync_store = SyncEventStoreAdapter(async_store, timeout=30.0)
version = sync_store.get_stream_version(stream_id)
The adapter's constructor takes a FullEventStore and a timeout (30 seconds by
default, overridable per call via the keyword-only timeout argument). When no event
loop is running it drives each coroutine with asyncio.run wrapped in
asyncio.wait_for, raising TimeoutError when the bound is exceeded. Called from a
thread that already runs a loop it raises RuntimeError rather than deadlocking on
that loop. close() releases the wrapped store when the store owns a resource, and
the adapter doubles as a context manager that closes on exit.
Reference Pages¶
Six subsystems have dedicated reference pages, ordered below the way an event flows through the library: an event is defined, appended to a store, produced by an aggregate, short-circuited by a snapshot, consumed by a projection, and distributed over a bus.
| Page | Package(s) documented | Top-level exports covered |
|---|---|---|
| Events | eventsource.domain.event, eventsource.domain.event_registry |
10 |
| Event Stores | eventsource.ports, eventsource.adapters.{memory,postgresql,sqlite} |
15 (16 with the SQLite extra) |
| Aggregates | eventsource.domain.aggregate, eventsource.application.aggregates, eventsource.domain.decorators |
4 |
| Snapshots | eventsource.ports.snapshots, eventsource.application.aggregates.snapshotting, eventsource.adapters.{memory,postgresql,sqlite} |
7 of 11 |
| Projections | eventsource.application.projections, eventsource.ports.readmodels |
5 of 21 |
| Event Bus | eventsource.ports.bus, eventsource.adapters.{memory,redis,rabbitmq,kafka} |
20 |
Where a page documents more names than the barrel exports — snapshots, projections and read models — the extra names are imported from their own module. Each page states which.
Events — api/events.md (eventsource.domain.event, eventsource.domain.event_registry)¶
Covers DomainEvent, the frozen Pydantic base class every domain event derives from, and
the EventRegistry that maps event_type strings back to classes during
deserialization. The corresponding slice of the barrel
agree exactly: DomainEvent, EventRegistry, default_registry, register_event, the
four convenience functions that operate on default_registry (get_event_class,
get_event_class_or_none, is_event_registered, list_registered_events), and the two
registry exceptions EventTypeNotFoundError and DuplicateEventTypeError.
The page explains the event_type auto-derivation that DomainEvent.__init_subclass__
performs — subclassing sets the event_type field default to the class name, and
declaring a different literal logs a warning unless the class sets
suppress_event_type_warning = True — together with the _ensure_event_type validator
that applies the same rule when an event is built from a dict. Note that this hook derives
the name only; it does not add the class to any registry.
It also documents model_config = ConfigDict(frozen=True) and the copy-on-write helpers
that immutability implies (with_causation, with_metadata, with_aggregate_version,
each returning a new instance via model_copy), the serialization pair to_dict /
from_dict, the relationship predicates is_caused_by and is_correlated_with, and the
standard fields: event_id, event_type, event_version, occurred_at,
aggregate_id, aggregate_type, aggregate_version, tenant_id, actor_id,
correlation_id, causation_id, and metadata. Only aggregate_id and
aggregate_type are required; the rest carry defaults or default factories.
On the registry side the page covers EventRegistry — an RLock-guarded mapping with
register, get, get_or_none, contains, list_types, list_classes, unregister,
clear, and the len(), in, and iteration dunders — the module-level
default_registry, and the register_event decorator, which works bare, with an
event_type key override, or against an alternate registry for test isolation. The two
registry exceptions extend builtins rather than EventSourceError:
EventTypeNotFoundError is a KeyError whose message lists the available types, and
DuplicateEventTypeError is a ValueError raised only when a different class claims an
already-registered name (re-registering the same class is a no-op).
TenantDomainEvent — the tenant-scoped subclass — lives in
eventsource.domain.tenant_events and is described there.
Event Stores — eventsource.ports.store, eventsource.ports.envelopes, eventsource.ports.positions, eventsource.adapters.{memory,postgresql,sqlite}¶
No dedicated api/stores.md reference page exists yet; see the module docstrings and
src/eventsource/ports/ for the authoritative contract until one is written.
Covers the five composable store port Protocols — EventAppender, StreamReader,
EventLookup, GlobalEventFeed, CategoryQuery — plus the two unions built from them,
AggregateStore (append + stream read, what a repository needs) and FullEventStore
(all five). Also covers the value types those ports exchange: EventEnvelope,
AppendResult, StreamReadOptions, FeedReadOptions, CategoryReadOptions,
ReadDirection, Position, and ExpectedVersion.
Three backends implement FullEventStore structurally (no inheritance from the
Protocols): InMemoryEventStore for tests and prototypes, PostgreSQLEventStore over a
SQLAlchemy AsyncEngine, and SQLiteEventStore, which is exported only when aiosqlite
is installed and is self-initializing (no async with / initialize() needed).
Read it for the optimistic-concurrency semantics of append (raises
OptimisticLockError on conflict), the difference between the single-stream
read_stream, the category-scoped read_category, and the global read_all iterators,
and the port conformance suites in eventsource.testing.conformance_ports that a
third-party store implementation is expected to pass.
Aggregates — api/aggregates.md (eventsource.domain.aggregate, eventsource.application.aggregates.repository)¶
Covers AggregateRoot[TState: BaseModel], the abstract base holding state plus
uncommitted events, and DeclarativeAggregate[TState: BaseModel], which routes events
to methods marked with @handles instead of requiring a hand-written apply dispatch
— both in the entities ring, at eventsource.domain.aggregate. Each class declares its
own inline PEP 695 TState type parameter; it is not a shared module-level TypeVar.
AggregateRepository[TAggregate], which loads
an aggregate by replaying its stream, saves uncommitted events with an
expected_version, and optionally consults a snapshot store to skip part of the replay,
is one ring out, at eventsource.application.aggregates.repository.
AggregateRepository's TAggregate is a class-scoped PEP 695 type parameter, not
an exported name. The handles decorator itself is defined in
eventsource.domain.decorators and re-exported from the barrel; the same decorator is used by
DeclarativeProjection.
The page also covers the repository's snapshot policy surface — snapshot_mode,
has_snapshot_support, create_snapshot, pending_snapshot_count, and
await_pending_snapshots — because snapshotting is configured at the repository, not on
the aggregate.
Snapshots — api/snapshots.md (eventsource.ports.snapshots, eventsource.application.aggregates.snapshotting)¶
Covers the Snapshot value object, the SnapshotStore interface (both in
eventsource.ports.snapshots), its three backend adapters, the four-member exception
hierarchy (eventsource.domain.exceptions), and the SnapshotPolicy / SnapshotScheduler
collaborators in eventsource.application.aggregates.snapshotting — the composable
replacement for ADR 0017's SnapshotStrategy — that decide when and how a snapshot is
written (see ADR 0021).
This is the clearest case of a page documenting more than the barrel exports. The
top-level package re-exports Snapshot, SnapshotStore, InMemorySnapshotStore, and
the four Snapshot*Error types. PostgreSQLSnapshotStore (from
eventsource.adapters.postgresql), SQLiteSnapshotStore and its own
SQLiteNotAvailableError (from eventsource.adapters.sqlite) are not re-exported at
the top level, though top-level AIOSQLITE_AVAILABLE does cover SQLite snapshot support;
SnapshotPolicy, SnapshotScheduler, and their implementations are a further
module-level import from eventsource.application.aggregates.snapshotting. Persistent
snapshot storage is therefore always written path-only, e.g.
from eventsource.adapters.postgresql import PostgreSQLSnapshotStore.
The page's central invariant: a snapshot is an optimization artifact, never the source of truth. A missing, unreadable, or schema-mismatched snapshot degrades to a full event replay rather than surfacing an error.
Projections — api/projections.md (eventsource.application.projections.base, eventsource.ports.readmodels)¶
Covers the read side. From eventsource.application.projections: the Projection base
class, CheckpointTrackingProjection for resumable consumers, and DeclarativeProjection
for @handles-based routing. DatabaseProjection lives in eventsource.adapters.sql
instead -- its constructor takes a SQLAlchemy async_sessionmaker, which makes it an
adapter, not an application-ring class. The barrel exports Projection,
CheckpointTrackingProjection, DeclarativeProjection, and DatabaseProjection plus
ReadModelProjection and the replay() rebuild driver with its ReplayReport /
ReplayFailure / ReplayFailedError types; the application.projections package
additionally exports
SyncProjection, EventHandlerBase, the TenantFilter alias, the handles /
get_handled_event_type / is_event_handler helpers, and the ProjectionRegistry,
ProjectionCoordinator, and SubscriberRegistry that drive projections as a group. The
retry policies (eventsource.application.projections.retry) and the checkpoint/DLQ
functions (eventsource.application.projections.checkpoints,
eventsource.application.projections.dlq) are documented on the same page but must be
imported from their own submodules.
From eventsource.ports.readmodels: the ReadModel base class, the
ReadModelRepository protocol, the Query and Filter builders, and the read-model
exception types. The in-memory, PostgreSQL, and SQLite implementations and the
schema-generation helpers live in their adapter modules
(eventsource.adapters.{memory,postgresql,sqlite}.readmodels,
eventsource.adapters.sql.readmodel_schema). Only ReadModelProjection reaches the
top-level barrel. (eventsource.readmodels no longer exists -- see ADR 0030.)
Event Bus — api/bus.md (eventsource.ports.bus, eventsource.adapters.{memory,redis,rabbitmq,kafka})¶
Covers the EventBus abstract base class — async def publish, plus the synchronous
subscribe, unsubscribe, subscribe_all, subscribe_to_all_events, and
unsubscribe_from_all_events — the EventHandlerFunc alias its subscribe methods
accept, and the handler and subscriber protocols re-exported from eventsource.ports.handlers
(EventHandler, AsyncEventHandler, SyncEventHandler, FlexibleEventHandler,
EventSubscriber, FlexibleEventSubscriber).
Four implementations are documented: InMemoryEventBus for in-process delivery, and the
Redis Streams, RabbitMQ, and Kafka buses. Each of the three external backends contributes
a consistent quartet — <Backend>EventBus, <Backend>EventBusConfig,
<Backend>EventBusStats, and <Backend>NotAvailableError — alongside its availability
flag. All twenty names are exported from their own adapter package and the top-level barrel,
whether or not the drivers are installed; see
Optional Backends and *_AVAILABLE Flags for
why construction rather than import is where a missing driver is reported.
Import model: the top-level barrel vs. submodule imports¶
eventsource/__init__.py is a barrel: it imports names from the implementation
modules and re-binds them at package level. Two import styles therefore reach the same
objects, and they are the same object — the barrel rebinds, it does not wrap or copy.
from eventsource import DomainEvent # barrel
from eventsource.domain.event import DomainEvent # submodule (identical object)
Prefer the barrel for the public surface¶
For any name listed in eventsource.__all__, import from eventsource directly. The
barrel is the supported contract; the module paths beneath it
(eventsource.domain.event, eventsource.application.aggregates.repository,
eventsource.adapters.memory) are implementation detail and may be reorganized without
being treated as a breaking change, provided the barrel name keeps working.
from eventsource import (
AggregateRepository,
AggregateRoot,
DomainEvent,
InMemoryEventBus,
InMemoryEventStore,
handles,
)
Note that the barrel is flat, not namespaced: there are no eventsource.domain.X-style
attribute chains to walk from the package object. Everything public sits one level down
from eventsource.
Use submodule imports for the deep surface¶
Several public subsystems are intentionally not re-exported. They have their own
__all__ and must be imported from their own module:
| Module | Exported names | Typical import |
|---|---|---|
eventsource.testing |
11 | from eventsource.testing import EventBuilder, InMemoryTestHarness |
eventsource.application.subscriptions |
123 | from eventsource.application.subscriptions import SubscriptionManager, SubscriptionConfig |
eventsource.observability |
58 | from eventsource.observability import get_tracer, OTEL_AVAILABLE |
eventsource.application.migration |
66 | from eventsource.application.migration import Migration, MigrationConfig |
eventsource.ports.locks / eventsource.adapters.postgresql.locks |
5 | from eventsource.adapters.postgresql.locks import PostgreSQLLockManager |
The size difference is the reason for the split: subscriptions and migration alone
would more than double the barrel, and most applications need neither.
eventsource.ports.handlers, eventsource.domain.exceptions, and eventsource.domain.types sit in
between. Their commonly used members are re-exported, but the modules remain the
canonical definition sites — import from the module when you need a member the barrel
does not carry.
Import-time cost and side effects¶
Importing eventsource is not free of side effects, and two of them are worth knowing:
- Every backend module is imported eagerly, including the Redis, RabbitMQ, and
Kafka bus modules. Each of those guards its own driver import internally, so the
import succeeds without the driver installed — but the module object, its config
dataclass, and its
*NotAvailableErrorare all created regardless. The one exception is SQLite, whose imports are wrapped in atry/except ImportErrorin the barrel itself. - Event classes self-register on definition.
DomainEvent.__init_subclass__adds each subclass to the globaldefault_registry, so importing a module that defines events mutates process-global state. Importing the same event class twice under two different module paths is the usual cause ofDuplicateEventTypeError.
Because of (2), prefer a single canonical import path per event module in your own code.
Mixing from myapp.events import OrderPlaced and
from myapp import events; events.OrderPlaced is fine — both resolve to one module — but
arranging for the same file to be imported as two distinct modules (for example via a
mix of relative and sys.path-relative imports) is not.
What not to import¶
eventsource._internal no longer exists as a package; its one module,
BackgroundTaskManager, moved to eventsource.application.background_tasks. Likewise, treat the raw SQL under
eventsource.adapters.sql.schemas as data applied by tooling rather than an importable API.
Version metadata (__version__, importlib.metadata, and the 0.0.0.dev0 source fallback)¶
eventsource.__version__ is the only version attribute in the public surface, and it is
listed first in __all__. It is a plain str, resolved once at import time:
from importlib.metadata import PackageNotFoundError, version
try:
__version__ = version("eventsource-py")
except PackageNotFoundError:
__version__ = "0.0.0.dev0"
Two properties follow from that snippet and are worth stating explicitly.
The value comes from installed distribution metadata, not from the source tree¶
The lookup key is the distribution name eventsource-py, not the import name
eventsource. importlib.metadata.version() reads the Version field of the installed
distribution's metadata, so __version__ reflects what the environment actually has
installed. The version = "0.5.0" line in pyproject.toml is the input to that metadata
at build/install time; it is not read at runtime.
Practical consequences:
- An editable install (
uv sync,pip install -e .) still produces real metadata, so__version__is the version recorded when the editable install was created. Bumpingpyproject.tomlwithout reinstalling leaves the old value in place. - The value is computed at module import and then bound; it is not a property and does not re-read metadata on subsequent access.
- Because the key is the distribution name, a differently-named fork or vendored copy
that keeps the
eventsourceimport name will hit the fallback path below unless it is installed aseventsource-py.
0.0.0.dev0 means "not installed", not "unreleased"¶
PackageNotFoundError is raised when no distribution named eventsource-py is visible
on sys.path. The barrel catches it and substitutes the sentinel "0.0.0.dev0". This is
the value you see when the package is used straight from a source checkout — for
example a sys.path entry pointing at src/, or a container that copies the tree
without installing it.
The fallback is deliberate: importing eventsource never raises because of missing
metadata. Nothing else in the library branches on __version__, so a checkout reporting
0.0.0.dev0 behaves identically to an installed copy in every other respect.
The sentinel is a valid PEP 440 developmental
release, which means it compares as older than every real release under
packaging.version.Version. Code that gates on a minimum version therefore treats an
uninstalled checkout as "too old":
Reading the version without importing the package¶
When you need the installed version but do not want the import-time side effects described above (eager backend imports, event self-registration), query the metadata directly:
from importlib.metadata import version
version("eventsource-py") # raises PackageNotFoundError if not installed
This is the same call the barrel makes, minus the fallback — it raises rather than returning a sentinel, which is usually what packaging and diagnostic tooling wants.
What the version number does and does not promise¶
__version__ is a release identifier, not a capability probe. It tells you which
release of the library is installed; it says nothing about which optional drivers are
present, because extras are installed alongside the same distribution version. Two
environments can both report 0.5.0 while one has aiosqlite and the other does not.
Use the *_AVAILABLE flags for capability questions — see
Runtime capability checks and feature detection recipes.
Always-available surface¶
The names in this part of the reference are importable from eventsource with only the
core dependencies installed — pydantic and sqlalchemy. No extra, no driver, no
running service is required to import them.
With every extra installed, eventsource.__all__ holds 107 names. Without the SQLite
extra it holds 102: the five SQLite names (AIOSQLITE_AVAILABLE, SQLiteEventStore,
SQLiteCheckpointRepository, SQLiteOutboxRepository, SQLiteDLQRepository) are the
only entries that are appended conditionally. Everything else in __all__ is bound
unconditionally by src/eventsource/__init__.py and is therefore always available in
the sense used here.
"Available" means importable, not necessarily usable¶
Three distinct guarantees are in play, and they are worth separating before reading the subsections below:
| Guarantee | Applies to |
|---|---|
| Imports, constructs, and runs with core deps only | DomainEvent, the type aliases, InMemoryEventStore, InMemoryEventBus, InMemorySnapshotStore, the in-memory repositories, aggregates, projections, protocols, multi-tenancy, SyncEventStoreAdapter, EventSourceJSONEncoder, exceptions |
| Imports and constructs, but needs a reachable service at runtime | PostgreSQLEventStore, PostgreSQLCheckpointRepository, PostgreSQLDLQRepository, PostgreSQLOutboxRepository (need asyncpg and a PostgreSQL server) |
| Imports, but raises at construction without its driver | RedisEventBus, KafkaEventBus, RabbitMQEventBus — see Optional backends and availability flags |
Only the first row is unconditionally safe to instantiate in a bare environment, which is why the in-memory implementations are the ones used throughout the tutorials and the test harness.
Why the surface is flat¶
Every name below is re-exported at package level. There is no barrel submodule
attribute chain to walk from the package object: from eventsource import DomainEvent,
AggregateRoot, InMemoryEventStore is the whole import model. The subsections that
follow group the names thematically for reading, not by module path — the grouping is
editorial, and the module a name happens to live in
(eventsource.ports.store, eventsource.ports.outbox, and so on) is
implementation detail for anything listed in __all__.
Ordering of the subsections¶
The groups run roughly in dependency order — types, then events, then the store interface, then the components built on top of it:
- Types — the identity aliases every other signature is written in terms of.
- Events and event registry —
DomainEventand the global registry it self-registers into. - Event store interface and data structures — the
EventStorecontract plus the value types it exchanges. - Event store ports and implementations — the five store port Protocols and
InMemoryEventStore,PostgreSQLEventStore,SQLiteEventStore. - Aggregates —
AggregateRoot,DeclarativeAggregate,AggregateRepository,handles. - Event bus and handler protocols — publish/subscribe and the callable shapes the bus accepts.
- Projections and read models, repository infrastructure, and snapshots — the read side and its supporting persistence.
- Multi-tenancy, sync adapters, serialization, and exceptions — cross-cutting concerns.
Async unless noted¶
Store, bus, projection, repository, and snapshot-store methods are all coroutines and
must be awaited. The documented exceptions are SyncEventStoreAdapter, which wraps an
async store for synchronous callers, and tenant_scope_sync, the synchronous
counterpart of tenant_scope. Both are called out in their own subsections.
Overview¶
eventsource-py is organized as a small set of collaborating interfaces, each with one
or more interchangeable backends. The interfaces live in interface.py / base.py
modules and the backends are colocated beside them, so choosing PostgreSQL over SQLite
or Redis over RabbitMQ is a construction-time decision that does not change call sites.
The write path¶
- A domain operation loads an aggregate through
AggregateRepository, which reads the aggregate's stream from anEventStore(optionally starting from aSnapshot) and replays it. - The aggregate — an
AggregateRoot, or aDeclarativeAggregatethat routes events to methods marked with@handles(EventType)— validates the command and records newDomainEventinstances. - The repository appends those events with an
ExpectedVersion, and the store raisesOptimisticLockErrorif another writer advanced the stream first. - A successful append returns an
AppendResult; the events, nowEventEnveloperecords carrying the stream identity, stream version, and an opaque globalPosition, are available to readers.
The read path¶
StreamReader.read_stream, GlobalEventFeed.read_all, and CategoryQuery.read_category
each yield an AsyncIterator[EventEnvelope], shaped respectively by StreamReadOptions
(which includes a ReadDirection), FeedReadOptions, and CategoryReadOptions.
Projection and its
subclasses — CheckpointTrackingProjection, DeclarativeProjection,
DatabaseProjection, ReadModelProjection — consume those events to build read models,
recording progress through a CheckpointRepository and diverting poison events to a
DLQRepository. The eventsource.application.subscriptions package drives projections
continuously, adding retry policy, health reporting, and flow control.
Distribution¶
An EventBus decouples producers from consumers. InMemoryEventBus is always usable;
RedisEventBus, RabbitMQEventBus, and KafkaEventBus back the same interface with
external brokers. Where atomicity between the store append and the publish matters, an
OutboxRepository records events transactionally for a separate relay to publish.
EventPublisher is the narrow protocol for "can publish events", satisfied by stores and
buses alike.
Cross-cutting concerns¶
| Concern | Surface |
|---|---|
| Multi-tenancy | TenantDomainEvent, tenant_context, tenant_scope, get_current_tenant — tenant identity is carried in a contextvar, not threaded through every call |
| Serialization | EventSourceJSONEncoder, used by the store backends to persist event payloads |
| Snapshots | SnapshotStore with in-memory, PostgreSQL, and SQLite implementations, to bound replay cost for long streams |
| Distributed locking | eventsource.ports.locks / eventsource.adapters.postgresql.locks — PostgreSQL advisory locks for single-writer coordination |
| Synchronous callers | SyncEventStoreAdapter, which wraps any async EventStore |
| Tracing | eventsource.observability, an optional OpenTelemetry integration |
| Testing | eventsource.testing — assertions, BDD helpers, a harness, and conformance suites that any EventStore or EventBus implementation can be run against |
What this page covers¶
The sections below walk the barrel group by group: importing conventions, then each core module (events, stores, aggregates, snapshots, projections, bus, locks), then the optional-dependency rules, the exception hierarchy, and the type aliases. Each group is summarized here and expanded on its own reference page, linked from See also.
Importing from eventsource¶
The top-level package is a barrel: src/eventsource/__init__.py re-exports the
user-facing names from the subpackages and declares them in __all__. Importing from
the barrel is the supported form, and the one used throughout this documentation:
from eventsource import (
DomainEvent,
EventStore,
InMemoryEventStore,
AggregateRoot,
AggregateRepository,
handles,
)
Deeper import paths (from eventsource.ports.store import FullEventStore) resolve to
the same objects, but the module layout beneath the barrel is an implementation detail
and may be rearranged; only the barrel names and the documented submodule entry points
carry compatibility guarantees.
What the barrel exports¶
__all__ groups the exports by concern. In source order:
| Group | Names |
|---|---|
| Version | __version__ |
| Type aliases | AggregateId, EventId, TenantId, CorrelationId, CausationId |
| Events | DomainEvent |
| Event registry | EventRegistry, default_registry, register_event, get_event_class, get_event_class_or_none, is_event_registered, list_registered_events, EventTypeNotFoundError, DuplicateEventTypeError |
| Store ports | EventAppender, StreamReader, EventLookup, GlobalEventFeed, CategoryQuery, AggregateStore, FullEventStore, EventEnvelope, AppendResult, StreamReadOptions, FeedReadOptions, CategoryReadOptions, ReadDirection, Position, ExpectedVersion |
| Store backends | InMemoryEventStore, PostgreSQLEventStore, SQLiteEventStore (conditional) |
| Aggregates | AggregateRoot, AggregateRepository, DeclarativeAggregate, handles |
| Event bus | EventBus, EventHandlerFunc, AsyncEventHandler, InMemoryEventBus |
| Handler protocols | EventHandler, SyncEventHandler, FlexibleEventHandler, EventSubscriber, FlexibleEventSubscriber |
| Redis bus | RedisEventBus, RedisEventBusConfig, RedisEventBusStats, RedisNotAvailableError, REDIS_AVAILABLE |
| RabbitMQ bus | RabbitMQEventBus, RabbitMQEventBusConfig, RabbitMQEventBusStats, RabbitMQNotAvailableError, RABBITMQ_AVAILABLE |
| Kafka bus | KafkaEventBus, KafkaEventBusConfig, KafkaEventBusStats, KafkaNotAvailableError, KAFKA_AVAILABLE |
| Exceptions | EventSourceError, AggregateNotCreatedError, AggregateNotFoundError, EventNotFoundError, EventVersionError, OptimisticLockError, ProjectionError |
| Checkpoints | CheckpointRepository, PostgreSQLCheckpointRepository, InMemoryCheckpointRepository, CheckpointData, LagMetrics |
| Dead letter queue | DLQRepository, PostgreSQLDLQRepository, InMemoryDLQRepository, DLQEntry, DLQStats, ProjectionFailureCount |
| Outbox | OutboxRepository, PostgreSQLOutboxRepository, InMemoryOutboxRepository, OutboxEntry, OutboxStats |
| Serialization | EventSourceJSONEncoder |
| Projections | Projection, CheckpointTrackingProjection, DeclarativeProjection, DatabaseProjection, ReadModelProjection |
| Snapshots | Snapshot, SnapshotStore, InMemorySnapshotStore, SnapshotError, SnapshotDeserializationError, SnapshotSchemaVersionError, SnapshotNotFoundError |
| Sync adapter | SyncEventStoreAdapter |
| Multi-tenancy | tenant_context, tenant_scope, tenant_scope_sync, get_current_tenant, get_required_tenant, set_current_tenant, clear_tenant_context, TenantDomainEvent, TenantContextNotSetError, TenantMismatchError |
Every name in that table imports with only the core dependencies installed. The Redis,
RabbitMQ, and Kafka bus classes are unconditional imports — their modules guard the
driver import and set the matching *_AVAILABLE flag, so the class is importable even
without the extra and fails only when constructed. See
Optional Dependencies and Backend Availability.
The SQLite exception¶
SQLite is the one group whose barrel exports are conditional. __init__.py attempts the
imports inside a try/except ImportError, sets AIOSQLITE_AVAILABLE accordingly, and
extends __all__ only when the import succeeded:
if AIOSQLITE_AVAILABLE:
__all__.extend([
"AIOSQLITE_AVAILABLE",
"SQLiteEventStore",
"SQLiteCheckpointRepository",
"SQLiteOutboxRepository",
"SQLiteDLQRepository",
])
Consequences worth knowing:
from eventsource import AIOSQLITE_AVAILABLEalways works — the name is bound as a module attribute in both branches. Only its presence in__all__(and therefore infrom eventsource import *) is conditional.from eventsource import SQLiteEventStoreraisesImportErrorwhenaiosqliteis not installed, rather than deferring to construction time. Guard on the flag if your code must run in both configurations:
from eventsource import AIOSQLITE_AVAILABLE
if AIOSQLITE_AVAILABLE:
from eventsource import SQLiteEventStore
SQLiteSnapshotStore(eventsource.adapters.sqlite) is not imported at the top level at all, so it is not gated by the top-levelAIOSQLITE_AVAILABLEflag. Theeventsource.adapters.sqlitepackage exposes the sameAIOSQLITE_AVAILABLEflag (re-exported fromstore.py;snapshots.py's identically-guarded copy is module-internal, not re-exported, since the two always agree) -- import path-only and check that flag, or catchSQLiteNotAvailableErrorfrom the constructor.
Names imported from submodules¶
Some public surface is deliberately not re-exported at the top level, either because it is a cohesive namespace of its own or because it belongs to an optional integration. Import these from their module:
| Module | Contents |
|---|---|
eventsource.ports.locks / eventsource.adapters.postgresql.locks |
PostgreSQLLockManager (adapter); LockInfo, migration_lock_key (port); LockAcquisitionError, LockNotHeldError (eventsource.ports.exceptions) |
eventsource.application.subscriptions |
Subscription manager, runners, retry policy, health, and flow control |
eventsource.testing |
Assertions, BDD helpers, the test harness, builders, and the conformance suites |
eventsource.observability |
OpenTelemetry tracing integration (telemetry extra) |
eventsource.application.migration |
Live event-store migration tooling: dual-write, cutover, sync tracking |
eventsource.gdpr |
GDPR compliance utilities |
Two modules are re-exported and importable directly, because they are canonical
homes rather than optional add-ons: eventsource.domain.decorators (handles) and
eventsource.ports.handlers (the handler and subscriber protocols). Prefer the barrel.
eventsource._internal no longer exists as a package; its one module,
BackgroundTaskManager, moved to eventsource.application.background_tasks.
Type checking¶
The package ships a py.typed marker, so mypy and other checkers consume the inline
annotations directly — no stub package is required or published.
Events¶
eventsource.domain.event and eventsource.domain.event_registry define the event vocabulary: DomainEvent, the base class every
event subclasses, and EventRegistry, the name-to-class mapping used to rehydrate
persisted events. Both are re-exported from the barrel.
DomainEvent¶
A Pydantic BaseModel with model_config = ConfigDict(frozen=True) — instances are
immutable, and any "change" produces a copy. Subclasses add payload fields:
from uuid import UUID
from eventsource import DomainEvent
class OrderCreated(DomainEvent):
aggregate_type: str = "Order"
order_number: str
customer_id: UUID
Fields. Every event carries the following, independent of its payload:
| Field | Type | Default | Meaning |
|---|---|---|---|
event_id |
UUID |
uuid4() |
Unique identifier for this event instance |
event_type |
str |
class name (auto-derived) | Type name used for storage and registry lookup |
event_version |
int (>= 1) |
1 |
Schema version of this event type, for migrations |
occurred_at |
datetime |
datetime.now(UTC) |
When the event occurred |
aggregate_id |
UUID |
required | Aggregate the event belongs to |
aggregate_type |
str |
required | Aggregate type name, e.g. "Order" |
aggregate_version |
int (>= 1) |
1 |
Aggregate version after this event |
tenant_id |
UUID \| None |
None |
Tenant, for multi-tenant deployments |
actor_id |
str \| None |
None |
User or system that triggered the event |
correlation_id |
UUID |
uuid4() |
Links events belonging to one logical operation |
causation_id |
UUID \| None |
None |
The event_id of the event that caused this one |
metadata |
dict[str, Any] |
{} |
Free-form additional metadata |
aggregate_id and aggregate_type are the only required constructor arguments beyond
the subclass's own payload fields. aggregate_version defaults to 1 and is normally
overwritten by the aggregate when it records the event.
event_type derivation. DomainEvent.__init_subclass__ inspects the subclass body:
if it does not declare event_type, the field default is rewritten to the class name, so
OrderCreated().event_type == "OrderCreated". A model_validator(mode="before") applies
the same rule when constructing from a dict, so OrderCreated.model_validate({...}) and
from_dict behave identically to the keyword form.
Declaring event_type explicitly is supported and takes precedence — useful when the
stored name must stay stable across a class rename, or when a dotted naming scheme is
preferred:
class OrderCreated(DomainEvent):
event_type: str = "order_created_v2"
aggregate_type: str = "Order"
suppress_event_type_warning = True
order_number: str
When the explicit event_type differs from the class name, the class emits a
logging.warning at definition time. Setting the class variable
suppress_event_type_warning: ClassVar[bool] = True silences it; the warning is
advisory only and has no effect on behavior.
Copy-with methods. Because events are frozen, these return new instances rather than mutating:
| Method | Result |
|---|---|
with_causation(causing_event) |
Copy with causation_id set to causing_event.event_id and correlation_id inherited from causing_event |
with_metadata(**kwargs) |
Copy whose metadata is the existing dict merged with kwargs (new keys win) |
with_aggregate_version(version) |
Copy with aggregate_version set — typically called by the aggregate when recording |
Serialization. to_dict() is model_dump(mode="json"), so UUID values become
strings and datetime values become ISO-8601 strings; the result is directly
JSON-serializable. from_dict(data) is a classmethod wrapping model_validate and
raises Pydantic's ValidationError when the payload does not match the schema.
Comparison helpers. is_caused_by(event) returns whether this event's causation_id
equals event.event_id. is_correlated_with(event) returns whether both share a
correlation_id — that is, whether they belong to the same logical operation or saga.
Representations. __str__ renders EventType(event_id=…, aggregate_id=…,
version=…); __repr__ additionally includes event_type, aggregate_type,
tenant_id, and occurred_at.
TenantDomainEvent¶
eventsource.domain.tenant_events provides TenantDomainEvent, a DomainEvent subclass
that narrows tenant_id to required and validates it is not None. Its
with_tenant_context(...) constructor reads the tenant from the ambient contextvar
established by tenant_scope / tenant_context, so request handlers do not have to pass
the tenant explicitly. It is exported from the top-level eventsource package alongside
the tenant-context functions.
EventRegistry¶
Storage keeps event_type as a string; deserialization needs the class back. That
mapping lives in EventRegistry, a thread-safe dict guarded by an RLock.
Registration is explicit — defining a DomainEvent subclass does not add it to any
registry. Register with the decorator or the method:
from eventsource import register_event, DomainEvent
@register_event
class OrderCreated(DomainEvent):
aggregate_type: str = "Order"
@register_event(event_type="order.shipped")
class OrderShipped(DomainEvent):
aggregate_type: str = "Order"
register_event works with or without parentheses and accepts event_type= to override
the stored name and registry= to target a registry other than the default.
EventRegistry methods:
| Member | Behavior |
|---|---|
register(event_class, event_type=None) |
Registers and returns the class (so it composes as a decorator). Re-registering the same class under the same name is a no-op |
get(event_type) |
Returns the class; raises EventTypeNotFoundError if unknown |
get_or_none(event_type) |
Returns the class or None |
contains(event_type) / event_type in registry |
Membership test |
list_types() |
Sorted list of registered type names |
list_classes() |
Registered classes, ordered by type name |
unregister(event_type) |
Removes the entry; returns whether it was present |
clear() |
Empties the registry — intended for test isolation |
len(registry), iter(registry) |
Count of entries; iteration over type names |
Note that bool(registry) is always True, including for an empty registry; use
len(registry) to test for emptiness.
Name resolution. When event_type is not passed to register, the name is resolved
in order: the explicit argument, then the class's event_type field default if it is a
string, then the class name. Because __init_subclass__ has already set that default to
the class name for classes that do not declare one, the common case registers under the
class name.
Duplicates. Registering a different class under an already-registered name raises
DuplicateEventTypeError, which carries event_type, existing_class, and
new_class. Lookups for unknown names raise EventTypeNotFoundError, whose message
lists the available types; it carries event_type and available_types.
EventTypeNotFoundError subclasses KeyError and DuplicateEventTypeError subclasses
ValueError, so both are catchable through their builtin bases as well as by name.
Default registry. default_registry is a module-level EventRegistry instance. The
convenience functions get_event_class, get_event_class_or_none, is_event_registered,
and list_registered_events operate on it. Constructing a private EventRegistry() is
the recommended way to isolate tests from globally registered application events.
Event Stores¶
eventsource.ports.store defines the append-and-read contract as five narrow,
composable Protocols, plus the two unions built from them. eventsource.ports.envelopes
and eventsource.ports.positions define the data structures those ports exchange. All
of the names below are re-exported from the barrel, except SQLiteEventStore
(conditional — see The SQLite exception).
The five store ports¶
Each is a Protocol (not @runtime_checkable — use them as type annotations, not for
isinstance checks), so conformance is structural: an adapter satisfies a port by
having matching methods, with no inheritance required.
| Port | Method(s) | Signature |
|---|---|---|
EventAppender |
append |
(stream: StreamId, events: Sequence[DomainEvent], expected: ExpectedVersion) -> AppendResult |
StreamReader |
read_stream |
(stream: StreamId, options: StreamReadOptions \| None = None) -> AsyncIterator[EventEnvelope] |
get_stream_version |
(stream: StreamId) -> int |
|
EventLookup |
event_exists |
(event_id: UUID) -> bool |
GlobalEventFeed |
read_all |
(from_position: Position \| None = None, options: FeedReadOptions \| None = None) -> AsyncIterator[EventEnvelope] |
current_position |
() -> Position \| None |
|
CategoryQuery |
read_category |
(category: str, options: CategoryReadOptions \| None = None) -> AsyncIterator[EventEnvelope] |
Two composed unions:
AggregateStore=EventAppender+StreamReader. Deliberately narrower thanFullEventStore— this is whatAggregateRepositoryrequires, since a repository never reads the global feed, never queries a category, and never probes for an individual event id (interface segregation; see.claude/rules/architecture.md).FullEventStore= the union of all five ports.InMemoryEventStore,PostgreSQLEventStore, andSQLiteEventStoreall satisfy it structurally.
collect(it) (also in eventsource.ports.store) drains an
AsyncIterator[EventEnvelope] into a list — a convenience for tests and small reads.
Write semantics. append is the only write operation. Version conflicts raise
OptimisticLockError rather than being returned as data — there is no
AppendResult.conflicted(...)-style constructor; a successful append simply returns an
AppendResult. Appends are idempotent at event granularity in the bundled backends: an
event whose event_id is already stored is skipped rather than duplicated, so a retried
append does not produce a second copy.
Stream identity. Streams are identified by StreamId (eventsource.domain), not by
separate aggregate_id/aggregate_type arguments — a StreamId bundles both.
Data structures¶
All are frozen dataclasses (eventsource.ports.envelopes, eventsource.ports.positions).
EventEnvelope wraps a persisted DomainEvent with position metadata: event,
stream_id (a StreamId), stream_version (1-based, within the stream), position
(Position | None — None for feedless stores), and stored_at.
AppendResult reports the outcome of a write: stream (the StreamId),
new_version (the stream's version after the append), and position (Position | None
of the append, None for feedless stores).
StreamReadOptions configures a single-stream read:
| Field | Type | Default |
|---|---|---|
direction |
ReadDirection |
ReadDirection.FORWARD |
from_version |
int \| None |
None |
to_version |
int \| None |
None |
limit |
int \| None |
None (unbounded) |
FeedReadOptions configures a global-feed read: tenant_id (UUID | None,
default None for all tenants), aggregate_type (str | None, default None
for all types — the stream category, pushed into the adapter's WHERE clause),
and limit (int | None).
CategoryReadOptions configures a category read: tenant_id, from_timestamp
(datetime | None — filters on EventEnvelope.stored_at, not the event's own
occurred_at), and limit.
ReadDirection is an Enum with members FORWARD ("forward") and BACKWARD
("backward"), unchanged from the legacy interface.
Position (eventsource.ports.positions) is an opaque, frozen Position(store_id:
str, key: tuple). It is totally ordered within one store; comparing positions from two
different store_ids raises PositionForeignError. Consumers compare and persist
positions — never do arithmetic on them. to_str() / Position.from_str(raw)
round-trip a position through a JSON string for checkpoint storage, raising
PositionDecodeError on a malformed string.
ExpectedVersion replaced the old integer sentinels (ExpectedVersion.ANY = -1
etc. no longer exist). It is a frozen dataclass with kind: str (one of "any",
"no_stream", "stream_exists", "exact") and version: int | None, built through
classmethods rather than passed as a raw int:
| Constructor | kind |
Meaning |
|---|---|---|
ExpectedVersion.any_() |
"any" |
Skip the version check entirely |
ExpectedVersion.no_stream() |
"no_stream" |
The stream must not exist yet |
ExpectedVersion.stream_exists() |
"stream_exists" |
The stream must already have at least one event |
ExpectedVersion.exact(version) |
"exact" |
The stream must currently have exactly version events |
ExpectedVersion.exact(0) is therefore the "brand-new stream" case, equivalent to what
NO_STREAM used to express.
Backends¶
| Backend | Import | Requires | Constructed with |
|---|---|---|---|
InMemoryEventStore |
barrel, eventsource.adapters.memory |
core only | InMemoryEventStore(store_id: str = "memory", *, event_registry: EventRegistry \| None = None) |
PostgreSQLEventStore |
barrel, eventsource.adapters.postgresql |
asyncpg at runtime |
PostgreSQLEventStore(engine: AsyncEngine, event_registry=None, *, store_id=None, create_schema=False, outbox_enabled=False) |
SQLiteEventStore |
barrel when AIOSQLITE_AVAILABLE, eventsource.adapters.sqlite |
aiosqlite |
SQLiteEventStore(database: str, event_registry=None, *, store_id=None, wal_mode=True, busy_timeout=5000) |
All three implement FullEventStore structurally — no inheritance from the port
Protocols. All three also resolve every appended event's event_type through their
event_registry (defaulting to default_registry): the SQL adapters on read, where
they must reconstruct the class, and InMemoryEventStore on append, so an
unregistered event type fails identically in tests and in production.
InMemoryEventStore keeps events in dictionaries guarded by a lock, and loses
everything on process exit. It is the intended store for unit tests and prototypes, not
for production or multi-process deployments.
PostgreSQLEventStore takes an AsyncEngine as its only required positional
argument — it builds its own session factory internally, so there is no
session_factory parameter to pass:
from sqlalchemy.ext.asyncio import create_async_engine
from eventsource import PostgreSQLEventStore
engine = create_async_engine("postgresql+asyncpg://localhost/mydb")
store = PostgreSQLEventStore(engine, outbox_enabled=True)
Keyword-only arguments: store_id (defaults to f"pg:{database}"), create_schema
(default False — production deployments apply the canonical
migrations/schemas/events.sql out of band; pass True for tests/local dev to opt into
lazy CREATE TABLE IF NOT EXISTS schema creation), and outbox_enabled (default
False). With outbox_enabled=True, each appended event is also inserted into
event_outbox with status 'pending' in the same transaction as the event itself —
that shared transaction is what makes the outbox pattern reliable. The read-only
store_id and outbox_enabled properties expose the configuration.
SQLiteEventStore is self-initializing — there is no async with store: and no
await store.initialize() to call. A single aiosqlite connection is opened lazily on
first use and reused for the store's lifetime (required for ":memory:" databases,
whose contents live only as long as the connection that created them stays open):
from eventsource import SQLiteEventStore
store = SQLiteEventStore(":memory:")
# use it directly -- no context manager, no initialize() call
Constructor arguments beyond database and the positional-or-keyword event_registry
are keyword-only: store_id (defaults to f"sqlite:{database}"), wal_mode (default
True), and busy_timeout in milliseconds (default 5000). Because SQLite has no
native UUID or timestamp types, the backend stores UUIDs as hyphenated text, timestamps
as ISO-8601 text, and payloads as JSON text.
Synchronous access¶
SyncEventStoreAdapter (from the barrel, defined in eventsource.adapters.sync.adapter) wraps
any FullEventStore for callers that cannot await. It exposes plain-def counterparts
of the port methods (append, read_stream, get_stream_version, event_exists,
read_all, read_category, current_position), a wrapped_store property, and a
timeout property. See eventsource/sync/adapter.py for exact signatures — a parallel
effort keeps docs/api/sync.md in sync with it in more depth than summarized here.
Verifying a custom store¶
eventsource.testing.conformance_ports provides one ABC test mixin per port —
AppenderConformance, StreamReaderConformance, EventLookupConformance,
GlobalFeedConformance, CategoryQueryConformance — each with an abstract store
pytest fixture. Subclass the suite(s) matching the ports your adapter implements, supply
a store fixture yielding a fresh instance, and the suite verifies conformance to that
port's contract. This package replaces the older, single ABC-based
eventsource.testing.conformance.EventStoreConformanceSuite.
Aggregates¶
eventsource.domain.aggregate holds the consistency boundary: AggregateRoot, the base
class that turns commands into events and events into state, and DeclarativeAggregate,
which routes events to @handles-decorated methods. AggregateRepository, which loads
and saves aggregates through an EventStore, lives one ring out at
eventsource.application.aggregates.repository. All three are re-exported from the
barrel, as is handles (whose canonical home is eventsource.domain.decorators).
TAggregate is not exported anywhere — it is a class-scoped PEP 695 type parameter
on AggregateRepository, so there is no module-level object to import.
AggregateRoot¶
class AggregateRoot[TState: BaseModel](ABC) — the inline type parameter TState is
bound to pydantic.BaseModel, so an aggregate's state is a Pydantic model. The constructor takes
one argument, aggregate_id: UUID; version starts at 0 and state at None.
Two methods are abstract: _apply(event) -> None, which updates state for an event, and
_get_initial_state() -> TState | None.
Class attributes. Configured by overriding in the subclass body:
| Attribute | Default | Meaning |
|---|---|---|
aggregate_type |
required, no default | Type name written onto events and used by the repository. Subclasses must declare it; construction raises AggregateTypeNotSetError otherwise |
schema_version |
1 |
Schema version of TState. Increment when the model changes incompatibly; snapshots whose schema_version differs are discarded |
validate_versions |
True |
Whether a new event with an unexpected aggregate_version raises EventVersionError or only logs a warning |
Properties. aggregate_id, version (the version of the last applied event),
state (TState | None), uncommitted_events (a copy of the pending list, so
mutating it has no effect), and has_uncommitted_events.
Applying events. apply_event(event, is_new=True) is the single entry point. For
is_new=True it first checks that event.aggregate_version == self.version + 1; on
mismatch it raises EventVersionError(expected_version, actual_version, event_id,
aggregate_id) when validate_versions is True, or logs a warning and continues when
it is False. It then sets self._version = event.aggregate_version, calls _apply,
and — only for is_new=True — appends the event to the uncommitted list. Replay passes
is_new=False, which skips both the version check and the tracking.
load_from_history(events) replays a list in order with is_new=False.
get_next_version() returns version + 1. _raise_event(event) is an alias for
apply_event(event, is_new=True) that reads better inside command methods.
Recording events with less boilerplate. create_event(event_class, **kwargs)
constructs and applies an event, auto-populating aggregate_id, aggregate_type, and
aggregate_version=get_next_version(), plus tenant_id from the ambient tenant context
when one is set and tenant_id was not passed explicitly. Explicit keyword arguments
override every auto-populated value. It returns the created event.
from eventsource import DeclarativeAggregate, handles
class OrderAggregate(DeclarativeAggregate[OrderState]):
aggregate_type = "Order"
requires_creation_event = True
def ship(self, tracking_number: str) -> None:
if self.state.status != "paid":
raise ValueError("Cannot ship unpaid order")
self.create_event(OrderShipped, tracking_number=tracking_number)
The tenant lookup is a direct call to eventsource.domain.tenant_context.get_current_tenant()
(a same-ring, always-available domain sibling since ADR 0038 dissolved the multitenancy
package into domain/application), returning None when no tenant context is set.
Committing. mark_events_as_committed() clears the uncommitted list;
clear_uncommitted_events() clears it and returns what it cleared. The repository calls
the former after a successful append.
Snapshot hooks. _serialize_state() returns self._state.model_dump(mode="json"),
or {} when state is None. _restore_from_snapshot(state_dict, version) validates the
dict into the state model and sets the version; an empty dict only sets the version.
_get_state_type() recovers the concrete TState by walking the MRO's __orig_bases__
and raises RuntimeError if the class was not parameterized as AggregateRoot[StateType].
These are called by AggregateRepository, not by application code.
Identity. __eq__ and __hash__ are defined on aggregate_id alone — two instances
of different aggregate classes sharing an ID compare equal. __repr__ renders the class
name, id, version, and uncommitted count.
DeclarativeAggregate¶
class DeclarativeAggregate[TState: BaseModel](AggregateRoot[TState], ABC) implements _apply for you by
dispatching on type(event) through a per-subclass handler registry.
__init_subclass__ scans the class for methods carrying the _handles_event_type
attribute set by @handles and records them in a fresh _event_handlers dict — fresh
per subclass, so registries are not shared through inheritance.
Aggregate handlers are synchronous and take (self, event). (Projection handlers,
which use the same decorator, are async and take (self, context, event).)
Two ClassVar knobs:
| Attribute | Default | Meaning |
|---|---|---|
requires_creation_event |
False |
When True, _get_initial_state() need not be implemented — it returns None and the first handler establishes state |
unregistered_event_handling |
"ignore" |
What _apply does with an event that has no handler: "ignore" (silent), "warn" (log, listing the registered handlers), or "error" (raise UnhandledEventError) |
"ignore" is the default because forward compatibility usually matters more than
strictness: a replay that encounters a newer event type keeps working. Set "error"
during development to catch a missing @handles or a mistyped handler.
When requires_creation_event is False and the subclass does not override
_get_initial_state(), the inherited implementation raises NotImplementedError.
State access. DeclarativeAggregate narrows state to TState and raises
AggregateNotCreatedError when requires_creation_event=True and no event has been
applied yet. Two companions avoid the exception: state_or_none returns TState | None,
and is_created returns whether any state exists.
handles¶
handles(event_type) sets func._handles_event_type = event_type and returns the
function unchanged, so a decorated method is still an ordinary method. Discovery happens
in the base class's __init_subclass__. The companion helpers
get_handled_event_type(func) (returns the type or None) and is_event_handler(func)
are importable from eventsource.domain.decorators.
AggregateRepository¶
class AggregateRepository[TAggregate: AggregateRoot[Any]] mediates between
aggregates and the store.
from eventsource import AggregateRepository, InMemoryEventStore
repo: AggregateRepository[OrderAggregate] = AggregateRepository(
event_store=InMemoryEventStore(),
aggregate_factory=OrderAggregate,
# aggregate_type inferred from OrderAggregate.aggregate_type
)
Constructor. event_store, aggregate_factory (the aggregate class, called with
one UUID), then optional aggregate_type, event_publisher, snapshot_store,
snapshot_threshold, snapshot_mode, tracer, and enable_tracing.
aggregate_type is inferred from aggregate_factory.aggregate_type when omitted; an
explicit argument wins. Inference deliberately rejects "" and raises ValueError
with instructions; a subclass that never declares aggregate_type at all fails even
earlier, at aggregate construction, with AggregateTypeNotSetError.
Methods.
| Member | Behavior |
|---|---|
load(aggregate_id) |
Restores from a valid snapshot when one exists, reads events from that version (or 0), replays them, and returns the aggregate. Raises AggregateNotFoundError when there is neither snapshot nor event |
load_or_create(aggregate_id) |
load, falling back to a fresh version-0 instance on AggregateNotFoundError |
get_or_raise(aggregate_id) |
An alias for load, for call sites where the intent reads better |
create_new(aggregate_id) |
A new in-memory instance; persists nothing |
save(aggregate) |
Appends the uncommitted events, then commits, publishes, and possibly snapshots |
exists(aggregate_id) |
Whether the aggregate's stream has any events |
get_version(aggregate_id) |
The stream's current version, 0 if absent |
create_snapshot(aggregate) |
Snapshots immediately, whatever the mode; raises RuntimeError without a snapshot_store |
await_pending_snapshots() |
Awaits outstanding background snapshot tasks, returning how many; 0 without a snapshot store |
Read-only properties: aggregate_type, event_store, event_publisher,
snapshot_store, snapshot_threshold, snapshot_mode, has_snapshot_support, and
pending_snapshot_count.
Save semantics. save returns immediately when there is nothing uncommitted. The
expected version it passes to the store is aggregate.version - len(uncommitted_events)
— the version the stream had before this unit of work — which is what makes the store's
optimistic-locking check meaningful; a conflict surfaces as OptimisticLockError from
the store. Only after a successful append does the repository call
mark_events_as_committed(), await event_publisher.publish(events) if a publisher was
supplied, and consult the snapshot strategy. The publish is not in the store's
transaction; where that atomicity matters, use the outbox instead of a publisher.
Load and snapshots. Snapshot loading is best-effort at every step. A snapshot store
that raises, a missing snapshot, or a schema_version that does not match the aggregate
class all yield "no snapshot" and a full replay from version 0 rather than an error. If
the snapshot is found but _restore_from_snapshot fails to deserialize, the repository
logs a warning, re-fetches the whole stream, and rebuilds a fresh instance — so a stale
or corrupt snapshot degrades performance, never correctness.
Snapshot modes. With a snapshot_store configured, snapshot_mode selects when
automatic snapshots happen: "sync" (default) writes one inline after save,
"background" schedules a task, and "manual" never writes one automatically.
snapshot_threshold is the event count between automatic snapshots; leaving it None
means snapshots only happen through create_snapshot(). Snapshot failures are logged
and do not fail the save. create_snapshot upserts — an existing snapshot for the
aggregate is replaced.
load, save, exists, and create_snapshot emit OpenTelemetry spans when
enable_tracing is True and the telemetry extra is installed; without it the tracer
is a no-op.
Related exceptions¶
| Exception | Raised when |
|---|---|
EventVersionError |
A new event's aggregate_version is not version + 1 and validate_versions=True |
UnhandledEventError |
A DeclarativeAggregate with unregistered_event_handling="error" applies an event with no handler |
AggregateNotCreatedError |
state is read on a requires_creation_event=True aggregate before any event |
AggregateNotFoundError |
AggregateRepository.load finds neither snapshot nor events |
OptimisticLockError |
The store rejects the append in save because the stream moved on |
All five subclass EventSourceError and are re-exported from the barrel except
UnhandledEventError, which is imported from eventsource.domain.exceptions.
Types (AggregateId, EventId, TenantId, CorrelationId, CausationId)¶
Five names from eventsource.domain.types are re-exported at the top level, all plain
type aliases. The module no longer declares a TState TypeVar — the aggregate state
parameter is now an inline PEP 695 type parameter on AggregateRoot and
DeclarativeAggregate (see below) and is not an importable name. The full module
definition is one import and five assignments:
AggregateId = UUID
EventId = UUID
TenantId = UUID | None
CorrelationId = UUID
CausationId = UUID | None
| Name | Definition | Kind |
|---|---|---|
AggregateId |
UUID |
Alias |
EventId |
UUID |
Alias |
TenantId |
UUID \| None |
Alias |
CorrelationId |
UUID |
Alias |
CausationId |
UUID \| None |
Alias |
The aliases are aliases, not new types¶
None of these are NewType or a subclass. AggregateId is uuid.UUID — the same
object, evaluated at import time — so AggregateId is UUID holds, isinstance(x,
AggregateId) is just an isinstance check against UUID, and calling
AggregateId("...") constructs an ordinary UUID. They carry no runtime behavior and
no validation of their own.
They also give no type-checker separation: because AggregateId, EventId, and
CorrelationId all resolve to UUID, mypy accepts an EventId wherever an
AggregateId is expected. The aliases exist to make signatures self-describing, not to
prevent mixing up identifiers. If you need that separation in your own domain code,
define your own NewType on top.
Two of the six are nullable, and that nullability is the meaningful part of their definition:
TenantIdisUUID | None—Nonemeans "no tenant", the single-tenant or tenant-unscoped case.CausationIdisUUID | None—Nonemeans the event was not caused by another event, i.e. it originates a causation chain.
AggregateId, EventId, and CorrelationId are non-optional: every event has all
three.
Where the aliases correspond to event fields¶
The alias names mirror the identity fields on DomainEvent, which is where the
vocabulary comes from:
| Alias | DomainEvent field |
Field annotation |
|---|---|---|
EventId |
event_id |
UUID (defaults to uuid4()) |
AggregateId |
aggregate_id |
UUID (required) |
TenantId |
tenant_id |
UUID \| None |
CorrelationId |
correlation_id |
UUID |
CausationId |
causation_id |
UUID \| None |
Note that DomainEvent declares those fields with the bare UUID / UUID | None
annotations rather than the aliases. Since the aliases are equal to those types, the
distinction is stylistic — the fields and the aliases describe exactly the same values.
correlation_id and causation_id are the pair used for tracing: caused_by() copies
the causing event's correlation_id and sets causation_id to the causing event's
event_id, so a correlation id spans a whole workflow while a causation id points one
step back. See the events reference for the full field semantics.
TState and generic aggregates¶
TState is the inline type parameter that parameterizes the aggregate hierarchy —
declared on the class itself (class AggregateRoot[TState: BaseModel](ABC)), not as a
shared module-level TypeVar. Its bound is pydantic.BaseModel, so aggregate state
must be a Pydantic model — this is what lets AggregateRoot serialize state into
snapshots and validate it back out.
from pydantic import BaseModel
from eventsource import AggregateRoot
class OrderState(BaseModel):
order_number: str
total: int
class Order(AggregateRoot[OrderState]):
...
AggregateRoot is declared [TState: BaseModel] and DeclarativeAggregate redeclares
the same inline parameter over AggregateRoot[TState], so it flows through both. It also shapes the state
accessors, and the two base classes differ here:
AggregateRoot.statereturnsTState | None— state isNonebefore the creating event has been applied.DeclarativeAggregate.statereturnsTState(non-optional). It raisesAggregateNotCreatedErrorwhenrequires_creation_eventis true and no event has been applied yet;state_or_nonereturnsTState | Nonefor the cases where absence is expected.
The concrete argument is recovered at runtime, not only by the type checker:
_get_state_type() walks the class's generic bases to find the type substituted for
TState, and uses it to deserialize snapshot payloads. A subclass that never
parameterizes its base — class Order(AggregateRoot) with no [OrderState] — makes
that lookup fail with a RuntimeError, so parameterize the base even when you are not
type-checking.
Because the bound is enforced only by the type checker, substituting a non-BaseModel
type is a static error rather than a runtime one — but snapshot round-tripping calls
model_validate on the recovered type, so it will fail there.
Aliases not re-exported at the top level¶
For positions in the global feed, use the opaque Position value object from eventsource.ports.positions;
it is adapter-defined and immutable. Aggregate versions are plain int values in DomainEvent.aggregate_version;
stream positions are exposed as EventEnvelope.stream_version. See the
types and protocols reference for the complete module, including the identity aliases
(AggregateId, EventId, TenantId, CorrelationId, CausationId).
Events and event registry (DomainEvent, EventRegistry, default_registry, registry helpers, EventTypeNotFoundError, DuplicateEventTypeError)¶
Ten names in this group are re-exported at the top level. They come from two modules:
DomainEvent from eventsource.domain.event, and everything else from
eventsource.domain.event_registry.
| Name | Kind | Purpose |
|---|---|---|
DomainEvent |
pydantic.BaseModel subclass |
Frozen base class for all domain events |
EventRegistry |
Class | Thread-safe mapping of event-type name to event class |
default_registry |
EventRegistry instance |
Module-level singleton used by the helpers |
register_event |
Decorator / function | Registers an event class in a registry |
get_event_class |
Function | default_registry.get — raises when missing |
get_event_class_or_none |
Function | default_registry.get_or_none — returns None |
is_event_registered |
Function | default_registry.contains |
list_registered_events |
Function | Sorted list of registered type names |
EventTypeNotFoundError |
Exception (KeyError) |
Lookup of an unregistered type |
DuplicateEventTypeError |
Exception (ValueError) |
Two different classes claim one type name |
DomainEvent¶
DomainEvent is a Pydantic v2 model with model_config = ConfigDict(frozen=True).
Instances are immutable: attribute assignment raises, and every mutation helper returns
a new instance via model_copy.
Its fields, all declared on the base class:
| Field | Type | Default |
|---|---|---|
event_id |
UUID |
uuid4() |
event_type |
str |
"", replaced by the class name (see below) |
event_version |
int (ge=1) |
1 |
occurred_at |
datetime |
datetime.now(UTC) |
aggregate_id |
UUID |
required |
aggregate_type |
str |
required |
aggregate_version |
int (ge=1) |
1 |
tenant_id |
UUID \| None |
None |
actor_id |
str \| None |
None |
correlation_id |
UUID |
uuid4() |
causation_id |
UUID \| None |
None |
metadata |
dict[str, Any] |
{} |
aggregate_id and aggregate_type are the only two fields with no default. Subclasses
conventionally pin aggregate_type with a class-level default so callers need only pass
aggregate_id plus the event's own payload fields.
There is also one ClassVar, suppress_event_type_warning: bool = False, which is not a
model field.
event_type is derived from the class name¶
DomainEvent.__init_subclass__ inspects the subclass's own __dict__. If the subclass
did not declare event_type, the hook rewrites the field default to cls.__name__;
if it did declare a string that differs from the class name, the hook emits a
logging.warning unless the subclass also sets suppress_event_type_warning = True.
from uuid import UUID, uuid4
from eventsource import DomainEvent
class OrderCreated(DomainEvent):
aggregate_type: str = "Order"
order_number: str
customer_id: UUID
event = OrderCreated(aggregate_id=uuid4(), order_number="ORD-001", customer_id=uuid4())
assert event.event_type == "OrderCreated"
A model_validator(mode="before") named _ensure_event_type covers the dict-construction
paths (model_validate, from_dict, Event(**data)): when the incoming data omits
event_type or supplies an empty string, and the field default is empty, it substitutes
the class name. An explicit non-empty default set by the subclass is preserved.
Note that __init_subclass__ performs no registration. Defining an event subclass
does not add it to default_registry — registration is always explicit, via
@register_event or EventRegistry.register.
Instance methods¶
All four with_*/to_* helpers are non-mutating:
| Method | Returns | Behavior |
|---|---|---|
with_causation(causing_event) |
Self |
Copy with causation_id = causing_event.event_id and correlation_id = causing_event.correlation_id |
with_metadata(**kwargs) |
Self |
Copy with kwargs merged over the existing metadata |
with_aggregate_version(version) |
Self |
Copy with aggregate_version replaced; called by aggregates when recording |
to_dict() |
dict[str, Any] |
model_dump(mode="json") — UUIDs become strings, datetimes ISO strings |
from_dict(data) (classmethod) |
Self |
model_validate(data); raises pydantic.ValidationError on mismatch |
is_caused_by(event) |
bool |
self.causation_id == event.event_id |
is_correlated_with(event) |
bool |
self.correlation_id == event.correlation_id |
Because with_* methods go through model_copy(update=...), the update values are not
re-validated — they are assigned directly onto the copy.
__str__ renders "{event_type}(event_id=..., aggregate_id=..., version=...)";
__repr__ additionally includes aggregate_type, tenant_id, and occurred_at.
EventRegistry and default_registry¶
An EventRegistry is a dict[str, type[DomainEvent]] guarded by a threading.RLock.
All operations take the lock, so the registry is safe to read and write from multiple
threads.
| Member | Signature | Notes |
|---|---|---|
register |
(event_class, event_type=None) -> type[TEvent] |
Returns the class, so it works as a decorator |
get |
(event_type) -> type[DomainEvent] |
Raises EventTypeNotFoundError |
get_or_none |
(event_type) -> type[DomainEvent] \| None |
|
contains |
(event_type) -> bool |
Also reachable as event_type in registry |
list_types |
() -> list[str] |
Sorted |
list_classes |
() -> list[type[DomainEvent]] |
Ordered by type name |
unregister |
(event_type) -> bool |
True if it was present |
clear |
() -> None |
Resets the registry; intended for tests |
Dunder behavior: len(registry) is the number of entries, iter(registry) yields type
names from a snapshot copy (safe against concurrent mutation), and bool(registry) is
always True, including when empty — check len(registry) if you mean emptiness.
Type-name resolution in register follows a three-step order:
- the explicit
event_typeargument, if given; - the class's
model_fields["event_type"].default, if it is astr(for a subclass that did not override it,__init_subclass__has already set this to the class name); event_class.__name__.
Re-registering the same class under the same name is a silent no-op. Registering a
different class under a name already taken raises DuplicateEventTypeError.
default_registry is a module-level EventRegistry created at import time. It is
process-global shared state: the four helper functions all delegate to it, and clearing
it affects every consumer in the process. Construct a private EventRegistry() when you
need isolation — for example, per-test registries.
register_event¶
register_event is an overloaded decorator usable bare or called:
from eventsource import DomainEvent, EventRegistry, register_event
@register_event
class OrderCreated(DomainEvent):
aggregate_type: str = "Order"
@register_event(event_type="order.shipped")
class OrderShipped(DomainEvent):
aggregate_type: str = "Order"
test_registry = EventRegistry()
@register_event(registry=test_registry)
class OrderCancelled(DomainEvent):
aggregate_type: str = "Order"
Signature: register_event(event_class=None, *, event_type=None, registry=None). Both
keyword arguments are keyword-only. registry defaults to default_registry. Called
with a class positionally it registers immediately and returns the class; called with
keywords only it returns a decorator.
Note that event_type here overrides only the registry key. It does not change the
class's event_type field, so a class registered as "order.shipped" still produces
instances whose event_type is "OrderShipped" unless the field default is set to match.
Keeping the two aligned is what makes a round trip through to_dict() and
get_event_class(data["event_type"]) resolve back to the same class.
Module-level helpers¶
Four thin wrappers over default_registry:
from eventsource import (
get_event_class,
get_event_class_or_none,
is_event_registered,
list_registered_events,
)
get_event_class("OrderCreated") # type[DomainEvent]; raises if unknown
get_event_class_or_none("Nope") # None
is_event_registered("OrderCreated") # True
list_registered_events() # ['OrderCreated', 'OrderShipped', ...]
The typical deserialization path pairs get_event_class with from_dict:
payload = stored.to_dict()
event_class = get_event_class(payload["event_type"])
event = event_class.from_dict(payload)
EventTypeNotFoundError and DuplicateEventTypeError¶
Both are defined in eventsource.domain.exceptions and — unlike most exceptions in this
library — do not inherit from EventSourceError. They extend builtins instead, so
except KeyError and except ValueError catch them:
| Exception | Base | Attributes |
|---|---|---|
EventTypeNotFoundError |
KeyError |
event_type, available_types |
DuplicateEventTypeError |
ValueError |
event_type, existing_class, new_class |
EventTypeNotFoundError's message lists the sorted available types (or none) to make
missing-registration bugs self-diagnosing. DuplicateEventTypeError names both the
already-registered class and the one being rejected; the usual cause is two distinct
classes sharing a name, or one module imported twice under different paths so that its
event class object is created twice.
Event store ports and data structures (EventAppender, StreamReader, EventLookup, GlobalEventFeed, CategoryQuery, AggregateStore, FullEventStore, EventEnvelope, AppendResult, StreamReadOptions, FeedReadOptions, CategoryReadOptions, Position, ExpectedVersion, EventPublisher)¶
This group is defined across eventsource.ports.store (the five Protocols and the two
unions), eventsource.ports.envelopes (the value objects and ReadDirection),
eventsource.ports.positions (Position, ExpectedVersion), and eventsource.ports.bus
(EventPublisher) — none of which depends on any database driver; only pydantic
(through DomainEvent) and the standard library are required to import them. See the
Event Stores subsection above under
Always-available surface for the full member-by-member
tables (ports, backends, value objects, ExpectedVersion constructors); this section
adds only what that one omits.
EventPublisher¶
EventPublisher (eventsource.ports.bus) is a typing.Protocol with a single method:
Because it is a Protocol, conformance is structural: any object with a matching async
publish satisfies it without inheriting from anything. It is not decorated with
@runtime_checkable, so isinstance(obj, EventPublisher) raises TypeError — use it as
a type annotation, not a runtime check.
from eventsource import DomainEvent, EventPublisher
class NotificationPublisher:
async def publish(self, events: list[DomainEvent]) -> None:
for event in events:
await send_notification(event)
def wire(publisher: EventPublisher) -> None: ...
It is deliberately narrower than EventBus: a publisher only pushes events outward and
takes a batch, whereas EventBus also handles subscription. EventBus implementations
satisfy the protocol only if they expose a matching publish — check the bus reference
before relying on the substitution.