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.
Files changed (58) hide show
  1. eventsource/__init__.py +231 -0
  2. eventsource/aggregates/__init__.py +21 -0
  3. eventsource/aggregates/base.py +443 -0
  4. eventsource/aggregates/repository.py +294 -0
  5. eventsource/bus/__init__.py +76 -0
  6. eventsource/bus/interface.py +302 -0
  7. eventsource/bus/memory.py +580 -0
  8. eventsource/bus/redis.py +1487 -0
  9. eventsource/config.py +7 -0
  10. eventsource/events/__init__.py +33 -0
  11. eventsource/events/base.py +281 -0
  12. eventsource/events/registry.py +436 -0
  13. eventsource/exceptions.py +112 -0
  14. eventsource/migrations/SCHEMA_DESIGN.md +238 -0
  15. eventsource/migrations/__init__.py +319 -0
  16. eventsource/migrations/schemas/all.sql +170 -0
  17. eventsource/migrations/schemas/checkpoints.sql +165 -0
  18. eventsource/migrations/schemas/dlq.sql +303 -0
  19. eventsource/migrations/schemas/events.sql +113 -0
  20. eventsource/migrations/schemas/events_partitioned.sql +238 -0
  21. eventsource/migrations/schemas/outbox.sql +191 -0
  22. eventsource/migrations/schemas/sqlite_all.sql +154 -0
  23. eventsource/migrations/templates/alembic/all_tables.py.template +213 -0
  24. eventsource/migrations/templates/alembic/checkpoints.py.template +166 -0
  25. eventsource/migrations/templates/alembic/dlq.py.template +234 -0
  26. eventsource/migrations/templates/alembic/events.py.template +141 -0
  27. eventsource/migrations/templates/alembic/outbox.py.template +145 -0
  28. eventsource/migrations/templates/checkpoints.sql +165 -0
  29. eventsource/migrations/templates/dlq.sql +303 -0
  30. eventsource/migrations/templates/events.sql +113 -0
  31. eventsource/migrations/templates/events_partitioned.sql +238 -0
  32. eventsource/migrations/templates/outbox.sql +191 -0
  33. eventsource/migrations/templates/sqlite/checkpoints.sql +65 -0
  34. eventsource/migrations/templates/sqlite/dlq.sql +101 -0
  35. eventsource/migrations/templates/sqlite/events.sql +103 -0
  36. eventsource/migrations/templates/sqlite/outbox.sql +97 -0
  37. eventsource/projections/__init__.py +88 -0
  38. eventsource/projections/base.py +799 -0
  39. eventsource/projections/coordinator.py +542 -0
  40. eventsource/projections/decorators.py +102 -0
  41. eventsource/projections/protocols.py +161 -0
  42. eventsource/py.typed +0 -0
  43. eventsource/repositories/__init__.py +86 -0
  44. eventsource/repositories/_connection.py +65 -0
  45. eventsource/repositories/_json.py +84 -0
  46. eventsource/repositories/checkpoint.py +717 -0
  47. eventsource/repositories/dlq.py +1124 -0
  48. eventsource/repositories/outbox.py +816 -0
  49. eventsource/stores/__init__.py +45 -0
  50. eventsource/stores/in_memory.py +573 -0
  51. eventsource/stores/interface.py +696 -0
  52. eventsource/stores/postgresql.py +919 -0
  53. eventsource/stores/sqlite.py +978 -0
  54. eventsource/types.py +23 -0
  55. eventsource_py-0.1.0.dist-info/METADATA +332 -0
  56. eventsource_py-0.1.0.dist-info/RECORD +58 -0
  57. eventsource_py-0.1.0.dist-info/WHEEL +4 -0
  58. eventsource_py-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ ]