eventsource-py 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eventsource/__init__.py +231 -0
- eventsource/aggregates/__init__.py +21 -0
- eventsource/aggregates/base.py +443 -0
- eventsource/aggregates/repository.py +294 -0
- eventsource/bus/__init__.py +76 -0
- eventsource/bus/interface.py +302 -0
- eventsource/bus/memory.py +580 -0
- eventsource/bus/redis.py +1487 -0
- eventsource/config.py +7 -0
- eventsource/events/__init__.py +33 -0
- eventsource/events/base.py +281 -0
- eventsource/events/registry.py +436 -0
- eventsource/exceptions.py +112 -0
- eventsource/migrations/SCHEMA_DESIGN.md +238 -0
- eventsource/migrations/__init__.py +319 -0
- eventsource/migrations/schemas/all.sql +170 -0
- eventsource/migrations/schemas/checkpoints.sql +165 -0
- eventsource/migrations/schemas/dlq.sql +303 -0
- eventsource/migrations/schemas/events.sql +113 -0
- eventsource/migrations/schemas/events_partitioned.sql +238 -0
- eventsource/migrations/schemas/outbox.sql +191 -0
- eventsource/migrations/schemas/sqlite_all.sql +154 -0
- eventsource/migrations/templates/alembic/all_tables.py.template +213 -0
- eventsource/migrations/templates/alembic/checkpoints.py.template +166 -0
- eventsource/migrations/templates/alembic/dlq.py.template +234 -0
- eventsource/migrations/templates/alembic/events.py.template +141 -0
- eventsource/migrations/templates/alembic/outbox.py.template +145 -0
- eventsource/migrations/templates/checkpoints.sql +165 -0
- eventsource/migrations/templates/dlq.sql +303 -0
- eventsource/migrations/templates/events.sql +113 -0
- eventsource/migrations/templates/events_partitioned.sql +238 -0
- eventsource/migrations/templates/outbox.sql +191 -0
- eventsource/migrations/templates/sqlite/checkpoints.sql +65 -0
- eventsource/migrations/templates/sqlite/dlq.sql +101 -0
- eventsource/migrations/templates/sqlite/events.sql +103 -0
- eventsource/migrations/templates/sqlite/outbox.sql +97 -0
- eventsource/projections/__init__.py +88 -0
- eventsource/projections/base.py +799 -0
- eventsource/projections/coordinator.py +542 -0
- eventsource/projections/decorators.py +102 -0
- eventsource/projections/protocols.py +161 -0
- eventsource/py.typed +0 -0
- eventsource/repositories/__init__.py +86 -0
- eventsource/repositories/_connection.py +65 -0
- eventsource/repositories/_json.py +84 -0
- eventsource/repositories/checkpoint.py +717 -0
- eventsource/repositories/dlq.py +1124 -0
- eventsource/repositories/outbox.py +816 -0
- eventsource/stores/__init__.py +45 -0
- eventsource/stores/in_memory.py +573 -0
- eventsource/stores/interface.py +696 -0
- eventsource/stores/postgresql.py +919 -0
- eventsource/stores/sqlite.py +978 -0
- eventsource/types.py +23 -0
- eventsource_py-0.1.0.dist-info/METADATA +332 -0
- eventsource_py-0.1.0.dist-info/RECORD +58 -0
- eventsource_py-0.1.0.dist-info/WHEEL +4 -0
- eventsource_py-0.1.0.dist-info/licenses/LICENSE +21 -0
eventsource/__init__.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
eventsource - Production-ready event sourcing library for Python.
|
|
3
|
+
|
|
4
|
+
This library provides:
|
|
5
|
+
- Event Store with PostgreSQL and In-Memory backends
|
|
6
|
+
- Domain Event base class with Pydantic models
|
|
7
|
+
- Aggregate pattern with optimistic locking
|
|
8
|
+
- Projection system with checkpoint tracking and DLQ
|
|
9
|
+
- Event Bus with In-Memory and Redis Streams backends
|
|
10
|
+
- Transactional Outbox pattern
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
__version__ = version("eventsource-py")
|
|
17
|
+
except PackageNotFoundError:
|
|
18
|
+
# Package not installed (running from source without install)
|
|
19
|
+
__version__ = "0.0.0.dev0"
|
|
20
|
+
|
|
21
|
+
# Exceptions - available immediately
|
|
22
|
+
# Aggregates (Task 07, Task 08)
|
|
23
|
+
from eventsource.aggregates.base import AggregateRoot, DeclarativeAggregate, handles
|
|
24
|
+
from eventsource.aggregates.repository import AggregateRepository
|
|
25
|
+
|
|
26
|
+
# Event bus (Task 10)
|
|
27
|
+
from eventsource.bus.interface import (
|
|
28
|
+
AsyncEventHandler,
|
|
29
|
+
EventBus,
|
|
30
|
+
EventHandler,
|
|
31
|
+
EventHandlerFunc,
|
|
32
|
+
EventSubscriber,
|
|
33
|
+
)
|
|
34
|
+
from eventsource.bus.memory import InMemoryEventBus
|
|
35
|
+
|
|
36
|
+
# Redis Event bus (Task 11)
|
|
37
|
+
from eventsource.bus.redis import (
|
|
38
|
+
REDIS_AVAILABLE,
|
|
39
|
+
RedisEventBus,
|
|
40
|
+
RedisEventBusConfig,
|
|
41
|
+
RedisEventBusStats,
|
|
42
|
+
RedisNotAvailableError,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Core event primitives (Task 02)
|
|
46
|
+
from eventsource.events.base import DomainEvent
|
|
47
|
+
|
|
48
|
+
# Event registry (Task 03)
|
|
49
|
+
from eventsource.events.registry import (
|
|
50
|
+
DuplicateEventTypeError,
|
|
51
|
+
EventRegistry,
|
|
52
|
+
EventTypeNotFoundError,
|
|
53
|
+
default_registry,
|
|
54
|
+
get_event_class,
|
|
55
|
+
get_event_class_or_none,
|
|
56
|
+
is_event_registered,
|
|
57
|
+
list_registered_events,
|
|
58
|
+
register_event,
|
|
59
|
+
)
|
|
60
|
+
from eventsource.exceptions import (
|
|
61
|
+
AggregateNotFoundError,
|
|
62
|
+
EventNotFoundError,
|
|
63
|
+
EventSourceError,
|
|
64
|
+
EventVersionError,
|
|
65
|
+
OptimisticLockError,
|
|
66
|
+
ProjectionError,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Projections (Task 09)
|
|
70
|
+
from eventsource.projections.base import (
|
|
71
|
+
CheckpointTrackingProjection,
|
|
72
|
+
DatabaseProjection,
|
|
73
|
+
DeclarativeProjection,
|
|
74
|
+
Projection,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Repository infrastructure (Task 12)
|
|
78
|
+
from eventsource.repositories import (
|
|
79
|
+
CheckpointData,
|
|
80
|
+
CheckpointRepository,
|
|
81
|
+
DLQEntry,
|
|
82
|
+
DLQRepository,
|
|
83
|
+
DLQStats,
|
|
84
|
+
EventSourceJSONEncoder,
|
|
85
|
+
InMemoryCheckpointRepository,
|
|
86
|
+
InMemoryDLQRepository,
|
|
87
|
+
InMemoryOutboxRepository,
|
|
88
|
+
LagMetrics,
|
|
89
|
+
OutboxEntry,
|
|
90
|
+
OutboxRepository,
|
|
91
|
+
OutboxStats,
|
|
92
|
+
PostgreSQLCheckpointRepository,
|
|
93
|
+
PostgreSQLDLQRepository,
|
|
94
|
+
PostgreSQLOutboxRepository,
|
|
95
|
+
ProjectionFailureCount,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Event store implementations (Task 05, Task 06)
|
|
99
|
+
from eventsource.stores.in_memory import InMemoryEventStore
|
|
100
|
+
|
|
101
|
+
# Event store interface and data structures (Task 04)
|
|
102
|
+
from eventsource.stores.interface import (
|
|
103
|
+
AppendResult,
|
|
104
|
+
EventPublisher,
|
|
105
|
+
EventStore,
|
|
106
|
+
EventStream,
|
|
107
|
+
ExpectedVersion,
|
|
108
|
+
ReadDirection,
|
|
109
|
+
ReadOptions,
|
|
110
|
+
StoredEvent,
|
|
111
|
+
SyncEventStore,
|
|
112
|
+
)
|
|
113
|
+
from eventsource.stores.postgresql import PostgreSQLEventStore
|
|
114
|
+
|
|
115
|
+
# SQLite Event Store and Repositories (optional - requires aiosqlite)
|
|
116
|
+
try:
|
|
117
|
+
from eventsource.repositories.checkpoint import SQLiteCheckpointRepository # noqa: F401
|
|
118
|
+
from eventsource.repositories.dlq import SQLiteDLQRepository # noqa: F401
|
|
119
|
+
from eventsource.repositories.outbox import SQLiteOutboxRepository # noqa: F401
|
|
120
|
+
from eventsource.stores.sqlite import SQLiteEventStore # noqa: F401
|
|
121
|
+
|
|
122
|
+
SQLITE_AVAILABLE = True
|
|
123
|
+
except ImportError:
|
|
124
|
+
SQLITE_AVAILABLE = False
|
|
125
|
+
|
|
126
|
+
# Types - available immediately
|
|
127
|
+
from eventsource.types import (
|
|
128
|
+
AggregateId,
|
|
129
|
+
CausationId,
|
|
130
|
+
CorrelationId,
|
|
131
|
+
EventId,
|
|
132
|
+
TenantId,
|
|
133
|
+
TState,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
__all__ = [
|
|
137
|
+
# Version
|
|
138
|
+
"__version__",
|
|
139
|
+
# Types
|
|
140
|
+
"TState",
|
|
141
|
+
"AggregateId",
|
|
142
|
+
"EventId",
|
|
143
|
+
"TenantId",
|
|
144
|
+
"CorrelationId",
|
|
145
|
+
"CausationId",
|
|
146
|
+
# Events (Task 02)
|
|
147
|
+
"DomainEvent",
|
|
148
|
+
# Event Registry (Task 03)
|
|
149
|
+
"EventRegistry",
|
|
150
|
+
"default_registry",
|
|
151
|
+
"register_event",
|
|
152
|
+
"get_event_class",
|
|
153
|
+
"get_event_class_or_none",
|
|
154
|
+
"is_event_registered",
|
|
155
|
+
"list_registered_events",
|
|
156
|
+
"EventTypeNotFoundError",
|
|
157
|
+
"DuplicateEventTypeError",
|
|
158
|
+
# Event Store Interface and Data Structures (Task 04)
|
|
159
|
+
"EventStore",
|
|
160
|
+
"SyncEventStore",
|
|
161
|
+
"EventPublisher",
|
|
162
|
+
"EventStream",
|
|
163
|
+
"AppendResult",
|
|
164
|
+
"StoredEvent",
|
|
165
|
+
"ReadOptions",
|
|
166
|
+
"ReadDirection",
|
|
167
|
+
"ExpectedVersion",
|
|
168
|
+
# Event Store Implementations (Task 05, Task 06)
|
|
169
|
+
"InMemoryEventStore",
|
|
170
|
+
"PostgreSQLEventStore",
|
|
171
|
+
# Aggregates (Task 07, Task 08)
|
|
172
|
+
"AggregateRoot",
|
|
173
|
+
"AggregateRepository",
|
|
174
|
+
"DeclarativeAggregate",
|
|
175
|
+
"handles",
|
|
176
|
+
# Event Bus (Task 10)
|
|
177
|
+
"EventBus",
|
|
178
|
+
"EventHandler",
|
|
179
|
+
"EventSubscriber",
|
|
180
|
+
"EventHandlerFunc",
|
|
181
|
+
"AsyncEventHandler",
|
|
182
|
+
"InMemoryEventBus",
|
|
183
|
+
# Redis Event Bus (Task 11)
|
|
184
|
+
"RedisEventBus",
|
|
185
|
+
"RedisEventBusConfig",
|
|
186
|
+
"RedisEventBusStats",
|
|
187
|
+
"RedisNotAvailableError",
|
|
188
|
+
"REDIS_AVAILABLE",
|
|
189
|
+
# Exceptions
|
|
190
|
+
"AggregateNotFoundError",
|
|
191
|
+
"EventNotFoundError",
|
|
192
|
+
"EventSourceError",
|
|
193
|
+
"EventVersionError",
|
|
194
|
+
"OptimisticLockError",
|
|
195
|
+
"ProjectionError",
|
|
196
|
+
# Repository infrastructure (Task 12)
|
|
197
|
+
"CheckpointRepository",
|
|
198
|
+
"PostgreSQLCheckpointRepository",
|
|
199
|
+
"InMemoryCheckpointRepository",
|
|
200
|
+
"CheckpointData",
|
|
201
|
+
"LagMetrics",
|
|
202
|
+
"DLQRepository",
|
|
203
|
+
"PostgreSQLDLQRepository",
|
|
204
|
+
"InMemoryDLQRepository",
|
|
205
|
+
"DLQEntry",
|
|
206
|
+
"DLQStats",
|
|
207
|
+
"ProjectionFailureCount",
|
|
208
|
+
"OutboxRepository",
|
|
209
|
+
"PostgreSQLOutboxRepository",
|
|
210
|
+
"InMemoryOutboxRepository",
|
|
211
|
+
"OutboxEntry",
|
|
212
|
+
"OutboxStats",
|
|
213
|
+
"EventSourceJSONEncoder",
|
|
214
|
+
# Projections (Task 09)
|
|
215
|
+
"Projection",
|
|
216
|
+
"CheckpointTrackingProjection",
|
|
217
|
+
"DeclarativeProjection",
|
|
218
|
+
"DatabaseProjection",
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
# Conditionally add SQLite exports when aiosqlite is available
|
|
222
|
+
if SQLITE_AVAILABLE:
|
|
223
|
+
__all__.extend(
|
|
224
|
+
[
|
|
225
|
+
"SQLITE_AVAILABLE",
|
|
226
|
+
"SQLiteEventStore",
|
|
227
|
+
"SQLiteCheckpointRepository",
|
|
228
|
+
"SQLiteOutboxRepository",
|
|
229
|
+
"SQLiteDLQRepository",
|
|
230
|
+
]
|
|
231
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Aggregate pattern implementations for the eventsource library."""
|
|
2
|
+
|
|
3
|
+
from eventsource.aggregates.base import (
|
|
4
|
+
AggregateRoot,
|
|
5
|
+
DeclarativeAggregate,
|
|
6
|
+
handles,
|
|
7
|
+
)
|
|
8
|
+
from eventsource.aggregates.repository import (
|
|
9
|
+
AggregateRepository,
|
|
10
|
+
TAggregate,
|
|
11
|
+
)
|
|
12
|
+
from eventsource.types import TState
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AggregateRoot",
|
|
16
|
+
"AggregateRepository",
|
|
17
|
+
"DeclarativeAggregate",
|
|
18
|
+
"handles",
|
|
19
|
+
"TAggregate",
|
|
20
|
+
"TState",
|
|
21
|
+
]
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base classes for event-sourced aggregates.
|
|
3
|
+
|
|
4
|
+
Aggregates are the consistency boundaries in event sourcing.
|
|
5
|
+
They maintain their state by applying events and emit new events
|
|
6
|
+
when commands are executed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Generic
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from eventsource.events.base import DomainEvent
|
|
16
|
+
from eventsource.exceptions import EventVersionError
|
|
17
|
+
from eventsource.types import TState
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Type alias for event handler functions
|
|
22
|
+
EventHandler = Callable[[DomainEvent], None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AggregateRoot(Generic[TState], ABC):
|
|
26
|
+
"""
|
|
27
|
+
Base class for event-sourced aggregate roots.
|
|
28
|
+
|
|
29
|
+
Aggregates are the primary building blocks in event sourcing. They:
|
|
30
|
+
- Maintain their state by applying events
|
|
31
|
+
- Track uncommitted events that need to be persisted
|
|
32
|
+
- Ensure business rule invariants are maintained
|
|
33
|
+
- Serve as consistency boundaries
|
|
34
|
+
|
|
35
|
+
The aggregate uses a generic type parameter `TState` to define the
|
|
36
|
+
shape of its internal state. This state must be a Pydantic BaseModel
|
|
37
|
+
to enable validation and serialization.
|
|
38
|
+
|
|
39
|
+
Subclasses must implement:
|
|
40
|
+
- `_apply(event)`: Update state based on event type
|
|
41
|
+
- `_get_initial_state()`: Return initial state for new aggregates
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
>>> class OrderState(BaseModel):
|
|
45
|
+
... order_id: UUID
|
|
46
|
+
... status: str = "pending"
|
|
47
|
+
... items: list[OrderItem] = []
|
|
48
|
+
...
|
|
49
|
+
>>> class OrderAggregate(AggregateRoot[OrderState]):
|
|
50
|
+
... def _get_initial_state(self) -> OrderState:
|
|
51
|
+
... return OrderState(order_id=self.aggregate_id)
|
|
52
|
+
...
|
|
53
|
+
... def _apply(self, event: DomainEvent) -> None:
|
|
54
|
+
... if isinstance(event, OrderCreated):
|
|
55
|
+
... self._state = OrderState(
|
|
56
|
+
... order_id=event.order_id,
|
|
57
|
+
... status="created",
|
|
58
|
+
... )
|
|
59
|
+
... elif isinstance(event, ItemAdded):
|
|
60
|
+
... self._state = self._state.model_copy(
|
|
61
|
+
... update={"items": [*self._state.items, event.item]}
|
|
62
|
+
... )
|
|
63
|
+
...
|
|
64
|
+
... def create(self, customer_id: UUID) -> None:
|
|
65
|
+
... if self.version > 0:
|
|
66
|
+
... raise ValueError("Order already created")
|
|
67
|
+
... event = OrderCreated(
|
|
68
|
+
... aggregate_id=self.aggregate_id,
|
|
69
|
+
... aggregate_type="Order",
|
|
70
|
+
... event_type="OrderCreated",
|
|
71
|
+
... customer_id=customer_id,
|
|
72
|
+
... aggregate_version=self.version + 1,
|
|
73
|
+
... )
|
|
74
|
+
... self.apply_event(event)
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
aggregate_id: Unique identifier for this aggregate instance
|
|
78
|
+
version: Current version (number of events applied)
|
|
79
|
+
_uncommitted_events: Events that haven't been persisted yet
|
|
80
|
+
_state: Current state of the aggregate
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# Class-level aggregate type (subclasses should override)
|
|
84
|
+
aggregate_type: str = "Unknown"
|
|
85
|
+
|
|
86
|
+
# Class-level configuration for version validation
|
|
87
|
+
# When True, events with incorrect versions will raise EventVersionError
|
|
88
|
+
# When False, version mismatches are logged as warnings but allowed
|
|
89
|
+
validate_versions: bool = True
|
|
90
|
+
|
|
91
|
+
# Class-level registry of event handlers (populated by decorator)
|
|
92
|
+
_event_handlers: dict[type[DomainEvent], str] = {}
|
|
93
|
+
|
|
94
|
+
def __init__(self, aggregate_id: UUID) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Initialize aggregate root.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
aggregate_id: Unique identifier for this aggregate
|
|
100
|
+
"""
|
|
101
|
+
self._aggregate_id = aggregate_id
|
|
102
|
+
self._version = 0
|
|
103
|
+
self._uncommitted_events: list[DomainEvent] = []
|
|
104
|
+
self._state: TState | None = None
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def aggregate_id(self) -> UUID:
|
|
108
|
+
"""Get the unique identifier for this aggregate."""
|
|
109
|
+
return self._aggregate_id
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def version(self) -> int:
|
|
113
|
+
"""Get the current version (number of events applied)."""
|
|
114
|
+
return self._version
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def state(self) -> TState | None:
|
|
118
|
+
"""
|
|
119
|
+
Get the current state of the aggregate.
|
|
120
|
+
|
|
121
|
+
Returns None for new aggregates that haven't had any events applied.
|
|
122
|
+
"""
|
|
123
|
+
return self._state
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def uncommitted_events(self) -> list[DomainEvent]:
|
|
127
|
+
"""
|
|
128
|
+
Get events that haven't been persisted yet.
|
|
129
|
+
|
|
130
|
+
Returns a copy to prevent external modification.
|
|
131
|
+
"""
|
|
132
|
+
return self._uncommitted_events.copy()
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def has_uncommitted_events(self) -> bool:
|
|
136
|
+
"""Check if there are events waiting to be persisted."""
|
|
137
|
+
return len(self._uncommitted_events) > 0
|
|
138
|
+
|
|
139
|
+
def apply_event(self, event: DomainEvent, is_new: bool = True) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Apply an event to the aggregate.
|
|
142
|
+
|
|
143
|
+
This method:
|
|
144
|
+
1. Validates the event version (for new events with validation enabled)
|
|
145
|
+
2. Updates the version to match the event's aggregate_version
|
|
146
|
+
3. Calls _apply() to update the state
|
|
147
|
+
4. If is_new=True, adds the event to uncommitted events
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
event: The domain event to apply
|
|
151
|
+
is_new: Whether this is a new event (True) or replayed from history (False)
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
EventVersionError: If version validation is enabled (validate_versions=True),
|
|
155
|
+
is_new=True, and the event version doesn't match expected
|
|
156
|
+
(current version + 1)
|
|
157
|
+
|
|
158
|
+
Note:
|
|
159
|
+
When replaying events from history, pass is_new=False to avoid
|
|
160
|
+
adding them to the uncommitted events list and to skip version validation.
|
|
161
|
+
|
|
162
|
+
Example:
|
|
163
|
+
>>> # New event (will be tracked for persistence)
|
|
164
|
+
>>> aggregate.apply_event(order_created, is_new=True)
|
|
165
|
+
>>>
|
|
166
|
+
>>> # Replayed event (from event store)
|
|
167
|
+
>>> aggregate.apply_event(historic_event, is_new=False)
|
|
168
|
+
"""
|
|
169
|
+
# Validate version for new events (not historical replay)
|
|
170
|
+
if is_new:
|
|
171
|
+
expected_version = self._version + 1
|
|
172
|
+
if event.aggregate_version != expected_version:
|
|
173
|
+
if self.validate_versions:
|
|
174
|
+
raise EventVersionError(
|
|
175
|
+
expected_version=expected_version,
|
|
176
|
+
actual_version=event.aggregate_version,
|
|
177
|
+
event_id=event.event_id,
|
|
178
|
+
aggregate_id=self._aggregate_id,
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
# Log warning when validation is disabled but versions don't match
|
|
182
|
+
logger.warning(
|
|
183
|
+
"Version mismatch (validation disabled): expected %d, got %d "
|
|
184
|
+
"for aggregate %s, event %s",
|
|
185
|
+
expected_version,
|
|
186
|
+
event.aggregate_version,
|
|
187
|
+
self._aggregate_id,
|
|
188
|
+
event.event_id,
|
|
189
|
+
extra={
|
|
190
|
+
"aggregate_id": str(self._aggregate_id),
|
|
191
|
+
"expected_version": expected_version,
|
|
192
|
+
"actual_version": event.aggregate_version,
|
|
193
|
+
"event_id": str(event.event_id),
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Update version
|
|
198
|
+
self._version = event.aggregate_version
|
|
199
|
+
|
|
200
|
+
# Apply the event to update state
|
|
201
|
+
self._apply(event)
|
|
202
|
+
|
|
203
|
+
# If this is a new event, track it for persistence
|
|
204
|
+
if is_new:
|
|
205
|
+
self._uncommitted_events.append(event)
|
|
206
|
+
|
|
207
|
+
@abstractmethod
|
|
208
|
+
def _apply(self, event: DomainEvent) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Apply event to update aggregate state.
|
|
211
|
+
|
|
212
|
+
Subclasses must implement this to handle specific event types
|
|
213
|
+
and update the internal state accordingly.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
event: The domain event to apply
|
|
217
|
+
|
|
218
|
+
Example:
|
|
219
|
+
>>> def _apply(self, event: DomainEvent) -> None:
|
|
220
|
+
... if isinstance(event, OrderCreated):
|
|
221
|
+
... self._state = OrderState(...)
|
|
222
|
+
... elif isinstance(event, OrderShipped):
|
|
223
|
+
... self._state = self._state.model_copy(
|
|
224
|
+
... update={"status": "shipped"}
|
|
225
|
+
... )
|
|
226
|
+
"""
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
@abstractmethod
|
|
230
|
+
def _get_initial_state(self) -> TState:
|
|
231
|
+
"""
|
|
232
|
+
Get the initial state for a new aggregate.
|
|
233
|
+
|
|
234
|
+
Called when the first event is applied to set up the initial state.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Initial state instance
|
|
238
|
+
|
|
239
|
+
Example:
|
|
240
|
+
>>> def _get_initial_state(self) -> OrderState:
|
|
241
|
+
... return OrderState(order_id=self.aggregate_id)
|
|
242
|
+
"""
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
def mark_events_as_committed(self) -> None:
|
|
246
|
+
"""
|
|
247
|
+
Mark all uncommitted events as committed.
|
|
248
|
+
|
|
249
|
+
Called by the repository after events have been successfully
|
|
250
|
+
persisted to the event store.
|
|
251
|
+
|
|
252
|
+
Example:
|
|
253
|
+
>>> # After persisting events
|
|
254
|
+
>>> await event_store.append_events(...)
|
|
255
|
+
>>> aggregate.mark_events_as_committed()
|
|
256
|
+
"""
|
|
257
|
+
self._uncommitted_events.clear()
|
|
258
|
+
|
|
259
|
+
def load_from_history(self, events: list[DomainEvent]) -> None:
|
|
260
|
+
"""
|
|
261
|
+
Reconstitute aggregate state from event history.
|
|
262
|
+
|
|
263
|
+
Replays all events in order to rebuild the aggregate's state.
|
|
264
|
+
Events are applied with is_new=False so they aren't added to
|
|
265
|
+
uncommitted events.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
events: List of historical events in chronological order
|
|
269
|
+
|
|
270
|
+
Example:
|
|
271
|
+
>>> stream = await event_store.get_events(aggregate_id, "Order")
|
|
272
|
+
>>> aggregate = OrderAggregate(aggregate_id)
|
|
273
|
+
>>> aggregate.load_from_history(stream.events)
|
|
274
|
+
>>> print(f"Order state: {aggregate.state}")
|
|
275
|
+
"""
|
|
276
|
+
for event in events:
|
|
277
|
+
self.apply_event(event, is_new=False)
|
|
278
|
+
|
|
279
|
+
def get_next_version(self) -> int:
|
|
280
|
+
"""
|
|
281
|
+
Get the version number for the next event.
|
|
282
|
+
|
|
283
|
+
Useful when creating new events that need the correct
|
|
284
|
+
aggregate_version field.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Current version + 1
|
|
288
|
+
"""
|
|
289
|
+
return self._version + 1
|
|
290
|
+
|
|
291
|
+
def clear_uncommitted_events(self) -> list[DomainEvent]:
|
|
292
|
+
"""
|
|
293
|
+
Clear and return all uncommitted events.
|
|
294
|
+
|
|
295
|
+
This is an alternative to mark_events_as_committed() that also
|
|
296
|
+
returns the events, useful for repositories that need to process
|
|
297
|
+
the events before clearing them.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
List of uncommitted events that were cleared
|
|
301
|
+
"""
|
|
302
|
+
events = self._uncommitted_events.copy()
|
|
303
|
+
self._uncommitted_events.clear()
|
|
304
|
+
return events
|
|
305
|
+
|
|
306
|
+
def _raise_event(self, event: DomainEvent) -> None:
|
|
307
|
+
"""
|
|
308
|
+
Convenience method to create and apply a new event.
|
|
309
|
+
|
|
310
|
+
This is an alias for apply_event(event, is_new=True) that makes
|
|
311
|
+
the intent clearer when raising domain events from command methods.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
event: The domain event to raise and apply
|
|
315
|
+
|
|
316
|
+
Example:
|
|
317
|
+
>>> def create_order(self, customer_id: UUID) -> None:
|
|
318
|
+
... event = OrderCreated(
|
|
319
|
+
... aggregate_id=self.aggregate_id,
|
|
320
|
+
... aggregate_type=self.aggregate_type,
|
|
321
|
+
... aggregate_version=self.get_next_version(),
|
|
322
|
+
... customer_id=customer_id,
|
|
323
|
+
... )
|
|
324
|
+
... self._raise_event(event)
|
|
325
|
+
"""
|
|
326
|
+
self.apply_event(event, is_new=True)
|
|
327
|
+
|
|
328
|
+
def __repr__(self) -> str:
|
|
329
|
+
"""String representation of aggregate."""
|
|
330
|
+
return (
|
|
331
|
+
f"{self.__class__.__name__}("
|
|
332
|
+
f"id={self._aggregate_id}, "
|
|
333
|
+
f"version={self._version}, "
|
|
334
|
+
f"uncommitted={len(self._uncommitted_events)})"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def __eq__(self, other: object) -> bool:
|
|
338
|
+
"""Check equality based on aggregate ID."""
|
|
339
|
+
if not isinstance(other, AggregateRoot):
|
|
340
|
+
return NotImplemented
|
|
341
|
+
return self._aggregate_id == other._aggregate_id
|
|
342
|
+
|
|
343
|
+
def __hash__(self) -> int:
|
|
344
|
+
"""Hash based on aggregate ID."""
|
|
345
|
+
return hash(self._aggregate_id)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class DeclarativeAggregate(AggregateRoot[TState], ABC):
|
|
349
|
+
"""
|
|
350
|
+
Aggregate that uses decorators to register event handlers.
|
|
351
|
+
|
|
352
|
+
This class provides an alternative to the basic AggregateRoot that
|
|
353
|
+
uses a declarative pattern with the @handles decorator to register
|
|
354
|
+
event handlers, reducing boilerplate in the _apply method.
|
|
355
|
+
|
|
356
|
+
Example:
|
|
357
|
+
>>> class OrderAggregate(DeclarativeAggregate[OrderState]):
|
|
358
|
+
... aggregate_type = "Order"
|
|
359
|
+
...
|
|
360
|
+
... def _get_initial_state(self) -> OrderState:
|
|
361
|
+
... return OrderState(order_id=self.aggregate_id)
|
|
362
|
+
...
|
|
363
|
+
... @handles(OrderCreated)
|
|
364
|
+
... def _on_order_created(self, event: OrderCreated) -> None:
|
|
365
|
+
... self._state = OrderState(
|
|
366
|
+
... order_id=self.aggregate_id,
|
|
367
|
+
... customer_id=event.customer_id,
|
|
368
|
+
... status="created",
|
|
369
|
+
... )
|
|
370
|
+
...
|
|
371
|
+
... @handles(OrderShipped)
|
|
372
|
+
... def _on_order_shipped(self, event: OrderShipped) -> None:
|
|
373
|
+
... if self._state:
|
|
374
|
+
... self._state = self._state.model_copy(
|
|
375
|
+
... update={"status": "shipped"}
|
|
376
|
+
... )
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
def __init_subclass__(cls, **kwargs: object) -> None:
|
|
380
|
+
"""Initialize handler registry for each subclass."""
|
|
381
|
+
super().__init_subclass__(**kwargs)
|
|
382
|
+
# Each subclass gets its own handler registry
|
|
383
|
+
cls._event_handlers = {}
|
|
384
|
+
# Collect handlers from methods with _handles_event_type attribute
|
|
385
|
+
for name in dir(cls):
|
|
386
|
+
try:
|
|
387
|
+
method = getattr(cls, name)
|
|
388
|
+
if hasattr(method, "_handles_event_type"):
|
|
389
|
+
event_type = method._handles_event_type
|
|
390
|
+
cls._event_handlers[event_type] = name
|
|
391
|
+
except AttributeError:
|
|
392
|
+
# Some attributes might raise, skip them
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
def _apply(self, event: DomainEvent) -> None:
|
|
396
|
+
"""
|
|
397
|
+
Apply event using registered handlers.
|
|
398
|
+
|
|
399
|
+
Looks up the handler for the event type and calls it.
|
|
400
|
+
If no handler is found, raises a warning but doesn't fail.
|
|
401
|
+
"""
|
|
402
|
+
event_type = type(event)
|
|
403
|
+
handler_name = self._event_handlers.get(event_type)
|
|
404
|
+
if handler_name:
|
|
405
|
+
handler = getattr(self, handler_name)
|
|
406
|
+
handler(event)
|
|
407
|
+
# If no handler found, event is silently ignored
|
|
408
|
+
# This allows for forward compatibility when new events are added
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def handles(event_type: type[DomainEvent]) -> Callable[[Callable[..., None]], Callable[..., None]]:
|
|
412
|
+
"""
|
|
413
|
+
Decorator to register an event handler method.
|
|
414
|
+
|
|
415
|
+
Use this decorator on methods in a DeclarativeAggregate subclass
|
|
416
|
+
to automatically register them as handlers for specific event types.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
event_type: The DomainEvent subclass this handler processes
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
Decorator function that marks the method as a handler
|
|
423
|
+
|
|
424
|
+
Example:
|
|
425
|
+
>>> class OrderAggregate(DeclarativeAggregate[OrderState]):
|
|
426
|
+
... @handles(OrderCreated)
|
|
427
|
+
... def _on_order_created(self, event: OrderCreated) -> None:
|
|
428
|
+
... self._state = OrderState(...)
|
|
429
|
+
"""
|
|
430
|
+
|
|
431
|
+
def decorator(func: Callable[..., None]) -> Callable[..., None]:
|
|
432
|
+
func._handles_event_type = event_type # type: ignore[attr-defined]
|
|
433
|
+
return func
|
|
434
|
+
|
|
435
|
+
return decorator
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
__all__ = [
|
|
439
|
+
"AggregateRoot",
|
|
440
|
+
"DeclarativeAggregate",
|
|
441
|
+
"handles",
|
|
442
|
+
"TState",
|
|
443
|
+
]
|