eventable 0.1.0__tar.gz

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.
@@ -0,0 +1,281 @@
1
+ Metadata-Version: 2.3
2
+ Name: eventable
3
+ Version: 0.1.0
4
+ Summary: Domain Event Message Infrastructure
5
+ Author: Ioannis-Andreas Philippas
6
+ Author-email: Ioannis-Andreas Philippas <ioannis.philippas@entaxilabs.gr>
7
+ Requires-Dist: fastapi>=0.135.3
8
+ Requires-Dist: pika>=1.3.2
9
+ Requires-Dist: pydantic>=2.12.5
10
+ Requires-Dist: ruff>=0.15.9
11
+ Requires-Dist: starlette>=1.0.0
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+
15
+ # eventable
16
+
17
+ Domain event infrastructure for Python microservices.
18
+
19
+ Provides the building blocks to implement **domain events** following Domain-Driven Design: aggregate roots that collect events, an in-process dispatcher, and a RabbitMQ transport — with an optional plug-and-play FastAPI integration.
20
+
21
+ ---
22
+
23
+ ## Features
24
+
25
+ - Frozen, serializable `DomainEvent` dataclasses
26
+ - `AggregateRoot` (Pydantic) that accumulates and releases domain events
27
+ - `EventDispatcher` — runs in-process handlers first, then publishes to the broker
28
+ - RabbitMQ publisher & subscriber backed by a durable topic exchange
29
+ - **FastAPI plugin**: one-line lifespan setup + pure-ASGI middleware that dispatches events after every successful request without swallowing exceptions
30
+
31
+ ---
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install eventable
37
+ # or with uv
38
+ uv add eventable
39
+ ```
40
+
41
+ **Requirements:** Python 3.12+, a running RabbitMQ broker (for the infrastructure layer).
42
+
43
+ ---
44
+
45
+ ## Core concepts
46
+
47
+ ```
48
+ Request
49
+ └─ AggregateRoot.add_domain_event(event) # collect during business logic
50
+ └─ EventCollector.collect_events() # pull after request completes
51
+ └─ EventDispatcher.dispatch(event)
52
+ ├─ in-process handlers # same bounded context
53
+ └─ RabbitMQPublisher # cross-context via broker
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Quick start
59
+
60
+ ### 1. Define a domain event
61
+
62
+ ```python
63
+ from dataclasses import dataclass
64
+ from uuid import UUID
65
+ from eventable.domain_event import DomainEvent
66
+
67
+ @dataclass(frozen=True)
68
+ class OrderPlaced(DomainEvent):
69
+ order_id: UUID
70
+ total: str
71
+ ```
72
+
73
+ ### 2. Define an aggregate root
74
+
75
+ ```python
76
+ from uuid import uuid4
77
+ from datetime import datetime
78
+ from eventable.aggregate_root import AggregateRoot
79
+ from eventable.domain_event import generate_event_id
80
+
81
+ class Order(AggregateRoot):
82
+ order_id: UUID
83
+ total: str
84
+
85
+ def place(self) -> None:
86
+ self.add_domain_event(
87
+ OrderPlaced(
88
+ event_id=generate_event_id(),
89
+ occurred_at=datetime.now(),
90
+ order_id=self.order_id,
91
+ total=self.total,
92
+ )
93
+ )
94
+ ```
95
+
96
+ ### 3. Register in-process handlers
97
+
98
+ Edit `event_handlers.py` (generated alongside your app):
99
+
100
+ ```python
101
+ from eventable.event_managment.event_dispatcher import EventDispatcher
102
+ from myapp.events import OrderPlaced
103
+
104
+ def handle_order_placed(event: OrderPlaced) -> None:
105
+ print(f"Order {event.order_id} placed — sending confirmation email")
106
+
107
+ def register_handlers(dispatcher: EventDispatcher) -> None:
108
+ dispatcher.register(OrderPlaced, handle_order_placed)
109
+ ```
110
+
111
+ ---
112
+
113
+ ## FastAPI integration
114
+
115
+ ### Lifespan setup
116
+
117
+ `Eventable` connects to RabbitMQ on startup, registers your in-process handlers, and disconnects cleanly on shutdown. Wire it into FastAPI's async lifespan:
118
+
119
+ ```python
120
+ from contextlib import asynccontextmanager
121
+ from fastapi import FastAPI
122
+ from eventable.infrastructure.fastapi.eventable import Eventable, EventableSettings
123
+ from eventable.infrastructure.fastapi.middleware import DomainEventMiddleware
124
+
125
+ settings = EventableSettings(
126
+ rabbit_url="amqp://guest:guest@localhost/",
127
+ exchange_name="domain_events", # default
128
+ )
129
+ eventable = Eventable(settings)
130
+
131
+ @asynccontextmanager
132
+ async def lifespan(app: FastAPI):
133
+ await eventable.startup(app) # connects publisher, wires dispatcher
134
+ yield
135
+ await eventable.shutdown() # closes RabbitMQ connection
136
+
137
+ app = FastAPI(lifespan=lifespan)
138
+ app.add_middleware(DomainEventMiddleware)
139
+ ```
140
+
141
+ After startup, `app.state.publisher` and `app.state.dispatcher` are available throughout the application.
142
+
143
+ ### Middleware
144
+
145
+ `DomainEventMiddleware` is a **pure ASGI middleware** (not `BaseHTTPMiddleware`) so exceptions from your route handlers propagate cleanly to FastAPI's exception handlers — nothing is swallowed.
146
+
147
+ Per-request lifecycle:
148
+
149
+ 1. Attaches a fresh `EventCollector` to `request.state.collector`.
150
+ 2. Runs the route handler normally.
151
+ 3. After the response is sent, if the status code is **< 400**, pulls all collected events and dispatches them.
152
+ 4. On 4xx/5xx the collector is discarded — no events are dispatched for failed requests.
153
+
154
+ ### Collecting events in a route
155
+
156
+ ```python
157
+ from fastapi import Request
158
+ from eventable.event_managment.event_collector import EventCollector
159
+
160
+ @app.post("/orders")
161
+ def place_order(request: Request):
162
+ collector: EventCollector = request.state.collector
163
+
164
+ order = Order(order_id=uuid4(), total="99.99")
165
+ collector.track(order) # middleware will pull events from this aggregate
166
+ order.place()
167
+
168
+ return {"order_id": str(order.order_id)}
169
+ ```
170
+
171
+ ---
172
+
173
+ ## RabbitMQ subscriber (cross-context consumer)
174
+
175
+ Run a long-lived consumer process in a separate service to handle events published by another bounded context:
176
+
177
+ ```python
178
+ from eventable import rabbit_subscription
179
+ from myapp.events import OrderPlaced
180
+
181
+ def handle_order_placed(event: OrderPlaced) -> None:
182
+ # react to the event in this bounded context
183
+ ...
184
+
185
+ rabbit_subscription(
186
+ consumer_name="inventory-service",
187
+ rabbit_url="amqp://guest:guest@localhost/",
188
+ queue_name="inventory.order_placed",
189
+ handlers_map={
190
+ OrderPlaced: (handle_order_placed, {}),
191
+ },
192
+ )
193
+ ```
194
+
195
+ `rabbit_subscription` blocks, handles `SIGTERM`/`SIGINT` for graceful shutdown, and nacks messages that fail processing (sending them to the dead-letter queue if configured).
196
+
197
+ ---
198
+
199
+ ## Event serialization
200
+
201
+ Events are serialized to JSON automatically. The following field types are supported out of the box:
202
+
203
+ | Python type | JSON representation |
204
+ |---|---|
205
+ | `UUID` | `string` |
206
+ | `datetime` | ISO 8601 string |
207
+ | `Decimal` | `string` |
208
+ | `ValueObject` | `get_value()` result |
209
+ | `dict`, `list` | passed through recursively |
210
+
211
+ ### ValueObject
212
+
213
+ Implement the `ValueObject` protocol to have your value objects serialize transparently:
214
+
215
+ ```python
216
+ from eventable.value_object import ValueObject
217
+
218
+ class Money:
219
+ def __init__(self, amount: str):
220
+ self.value = amount
221
+
222
+ def get_value(self) -> str:
223
+ return self.value
224
+ ```
225
+
226
+ ---
227
+
228
+ ## Architecture overview
229
+
230
+ ```
231
+ eventable/
232
+ ├── domain_event.py # DomainEvent base dataclass + serialize/deserialize
233
+ ├── aggregate_root.py # AggregateRoot (Pydantic BaseModel)
234
+ ├── value_object.py # ValueObject protocol
235
+ ├── event_handlers.py # register_handlers() — your in-process handler hook
236
+
237
+ ├── event_managment/
238
+ │ ├── event_collector.py # Tracks aggregates, pulls events after request
239
+ │ ├── event_dispatcher.py # Runs handlers then publishes to broker
240
+ │ ├── event_publisher.py # Abstract EventPublisher
241
+ │ └── event_subscriber.py # Abstract EventSubscriber
242
+
243
+ └── infrastructure/
244
+ ├── rabbitmq/
245
+ │ ├── event_publisher.py # RabbitMQPublisher (thread-local channels)
246
+ │ └── event_subscriber.py # RabbitMQSubscriber (blocking consumer)
247
+ └── fastapi/
248
+ ├── eventable.py # Eventable + EventableSettings (lifespan plugin)
249
+ └── middleware.py # DomainEventMiddleware (pure ASGI)
250
+ ```
251
+
252
+ **RabbitMQ topology:** a single durable **topic exchange** (`domain_events` by default). Each event type is routed by its class name as the routing key. Consumers bind a durable queue to the exchange for the routing keys they care about.
253
+
254
+ ---
255
+
256
+ ## Configuration reference
257
+
258
+ ### `EventableSettings`
259
+
260
+ | Field | Type | Default | Description |
261
+ |---|---|---|---|
262
+ | `rabbit_url` | `str` | — | AMQP connection URL |
263
+ | `exchange_name` | `str` | `"domain_events"` | Topic exchange name |
264
+
265
+ ---
266
+
267
+ ## Running the tests
268
+
269
+ ```bash
270
+ uv run pytest tests/unit/ # pure unit tests, no broker needed
271
+ uv run pytest tests/infrastructure/ -k "not rabbitmq" # FastAPI plugin tests only
272
+
273
+ # Full suite including RabbitMQ integration tests:
274
+ RABBITMQ_URL=amqp://admin:admin@localhost:5672/ uv run pytest
275
+ ```
276
+
277
+ ---
278
+
279
+ ## License
280
+
281
+ MIT
@@ -0,0 +1,267 @@
1
+ # eventable
2
+
3
+ Domain event infrastructure for Python microservices.
4
+
5
+ Provides the building blocks to implement **domain events** following Domain-Driven Design: aggregate roots that collect events, an in-process dispatcher, and a RabbitMQ transport — with an optional plug-and-play FastAPI integration.
6
+
7
+ ---
8
+
9
+ ## Features
10
+
11
+ - Frozen, serializable `DomainEvent` dataclasses
12
+ - `AggregateRoot` (Pydantic) that accumulates and releases domain events
13
+ - `EventDispatcher` — runs in-process handlers first, then publishes to the broker
14
+ - RabbitMQ publisher & subscriber backed by a durable topic exchange
15
+ - **FastAPI plugin**: one-line lifespan setup + pure-ASGI middleware that dispatches events after every successful request without swallowing exceptions
16
+
17
+ ---
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install eventable
23
+ # or with uv
24
+ uv add eventable
25
+ ```
26
+
27
+ **Requirements:** Python 3.12+, a running RabbitMQ broker (for the infrastructure layer).
28
+
29
+ ---
30
+
31
+ ## Core concepts
32
+
33
+ ```
34
+ Request
35
+ └─ AggregateRoot.add_domain_event(event) # collect during business logic
36
+ └─ EventCollector.collect_events() # pull after request completes
37
+ └─ EventDispatcher.dispatch(event)
38
+ ├─ in-process handlers # same bounded context
39
+ └─ RabbitMQPublisher # cross-context via broker
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Quick start
45
+
46
+ ### 1. Define a domain event
47
+
48
+ ```python
49
+ from dataclasses import dataclass
50
+ from uuid import UUID
51
+ from eventable.domain_event import DomainEvent
52
+
53
+ @dataclass(frozen=True)
54
+ class OrderPlaced(DomainEvent):
55
+ order_id: UUID
56
+ total: str
57
+ ```
58
+
59
+ ### 2. Define an aggregate root
60
+
61
+ ```python
62
+ from uuid import uuid4
63
+ from datetime import datetime
64
+ from eventable.aggregate_root import AggregateRoot
65
+ from eventable.domain_event import generate_event_id
66
+
67
+ class Order(AggregateRoot):
68
+ order_id: UUID
69
+ total: str
70
+
71
+ def place(self) -> None:
72
+ self.add_domain_event(
73
+ OrderPlaced(
74
+ event_id=generate_event_id(),
75
+ occurred_at=datetime.now(),
76
+ order_id=self.order_id,
77
+ total=self.total,
78
+ )
79
+ )
80
+ ```
81
+
82
+ ### 3. Register in-process handlers
83
+
84
+ Edit `event_handlers.py` (generated alongside your app):
85
+
86
+ ```python
87
+ from eventable.event_managment.event_dispatcher import EventDispatcher
88
+ from myapp.events import OrderPlaced
89
+
90
+ def handle_order_placed(event: OrderPlaced) -> None:
91
+ print(f"Order {event.order_id} placed — sending confirmation email")
92
+
93
+ def register_handlers(dispatcher: EventDispatcher) -> None:
94
+ dispatcher.register(OrderPlaced, handle_order_placed)
95
+ ```
96
+
97
+ ---
98
+
99
+ ## FastAPI integration
100
+
101
+ ### Lifespan setup
102
+
103
+ `Eventable` connects to RabbitMQ on startup, registers your in-process handlers, and disconnects cleanly on shutdown. Wire it into FastAPI's async lifespan:
104
+
105
+ ```python
106
+ from contextlib import asynccontextmanager
107
+ from fastapi import FastAPI
108
+ from eventable.infrastructure.fastapi.eventable import Eventable, EventableSettings
109
+ from eventable.infrastructure.fastapi.middleware import DomainEventMiddleware
110
+
111
+ settings = EventableSettings(
112
+ rabbit_url="amqp://guest:guest@localhost/",
113
+ exchange_name="domain_events", # default
114
+ )
115
+ eventable = Eventable(settings)
116
+
117
+ @asynccontextmanager
118
+ async def lifespan(app: FastAPI):
119
+ await eventable.startup(app) # connects publisher, wires dispatcher
120
+ yield
121
+ await eventable.shutdown() # closes RabbitMQ connection
122
+
123
+ app = FastAPI(lifespan=lifespan)
124
+ app.add_middleware(DomainEventMiddleware)
125
+ ```
126
+
127
+ After startup, `app.state.publisher` and `app.state.dispatcher` are available throughout the application.
128
+
129
+ ### Middleware
130
+
131
+ `DomainEventMiddleware` is a **pure ASGI middleware** (not `BaseHTTPMiddleware`) so exceptions from your route handlers propagate cleanly to FastAPI's exception handlers — nothing is swallowed.
132
+
133
+ Per-request lifecycle:
134
+
135
+ 1. Attaches a fresh `EventCollector` to `request.state.collector`.
136
+ 2. Runs the route handler normally.
137
+ 3. After the response is sent, if the status code is **< 400**, pulls all collected events and dispatches them.
138
+ 4. On 4xx/5xx the collector is discarded — no events are dispatched for failed requests.
139
+
140
+ ### Collecting events in a route
141
+
142
+ ```python
143
+ from fastapi import Request
144
+ from eventable.event_managment.event_collector import EventCollector
145
+
146
+ @app.post("/orders")
147
+ def place_order(request: Request):
148
+ collector: EventCollector = request.state.collector
149
+
150
+ order = Order(order_id=uuid4(), total="99.99")
151
+ collector.track(order) # middleware will pull events from this aggregate
152
+ order.place()
153
+
154
+ return {"order_id": str(order.order_id)}
155
+ ```
156
+
157
+ ---
158
+
159
+ ## RabbitMQ subscriber (cross-context consumer)
160
+
161
+ Run a long-lived consumer process in a separate service to handle events published by another bounded context:
162
+
163
+ ```python
164
+ from eventable import rabbit_subscription
165
+ from myapp.events import OrderPlaced
166
+
167
+ def handle_order_placed(event: OrderPlaced) -> None:
168
+ # react to the event in this bounded context
169
+ ...
170
+
171
+ rabbit_subscription(
172
+ consumer_name="inventory-service",
173
+ rabbit_url="amqp://guest:guest@localhost/",
174
+ queue_name="inventory.order_placed",
175
+ handlers_map={
176
+ OrderPlaced: (handle_order_placed, {}),
177
+ },
178
+ )
179
+ ```
180
+
181
+ `rabbit_subscription` blocks, handles `SIGTERM`/`SIGINT` for graceful shutdown, and nacks messages that fail processing (sending them to the dead-letter queue if configured).
182
+
183
+ ---
184
+
185
+ ## Event serialization
186
+
187
+ Events are serialized to JSON automatically. The following field types are supported out of the box:
188
+
189
+ | Python type | JSON representation |
190
+ |---|---|
191
+ | `UUID` | `string` |
192
+ | `datetime` | ISO 8601 string |
193
+ | `Decimal` | `string` |
194
+ | `ValueObject` | `get_value()` result |
195
+ | `dict`, `list` | passed through recursively |
196
+
197
+ ### ValueObject
198
+
199
+ Implement the `ValueObject` protocol to have your value objects serialize transparently:
200
+
201
+ ```python
202
+ from eventable.value_object import ValueObject
203
+
204
+ class Money:
205
+ def __init__(self, amount: str):
206
+ self.value = amount
207
+
208
+ def get_value(self) -> str:
209
+ return self.value
210
+ ```
211
+
212
+ ---
213
+
214
+ ## Architecture overview
215
+
216
+ ```
217
+ eventable/
218
+ ├── domain_event.py # DomainEvent base dataclass + serialize/deserialize
219
+ ├── aggregate_root.py # AggregateRoot (Pydantic BaseModel)
220
+ ├── value_object.py # ValueObject protocol
221
+ ├── event_handlers.py # register_handlers() — your in-process handler hook
222
+
223
+ ├── event_managment/
224
+ │ ├── event_collector.py # Tracks aggregates, pulls events after request
225
+ │ ├── event_dispatcher.py # Runs handlers then publishes to broker
226
+ │ ├── event_publisher.py # Abstract EventPublisher
227
+ │ └── event_subscriber.py # Abstract EventSubscriber
228
+
229
+ └── infrastructure/
230
+ ├── rabbitmq/
231
+ │ ├── event_publisher.py # RabbitMQPublisher (thread-local channels)
232
+ │ └── event_subscriber.py # RabbitMQSubscriber (blocking consumer)
233
+ └── fastapi/
234
+ ├── eventable.py # Eventable + EventableSettings (lifespan plugin)
235
+ └── middleware.py # DomainEventMiddleware (pure ASGI)
236
+ ```
237
+
238
+ **RabbitMQ topology:** a single durable **topic exchange** (`domain_events` by default). Each event type is routed by its class name as the routing key. Consumers bind a durable queue to the exchange for the routing keys they care about.
239
+
240
+ ---
241
+
242
+ ## Configuration reference
243
+
244
+ ### `EventableSettings`
245
+
246
+ | Field | Type | Default | Description |
247
+ |---|---|---|---|
248
+ | `rabbit_url` | `str` | — | AMQP connection URL |
249
+ | `exchange_name` | `str` | `"domain_events"` | Topic exchange name |
250
+
251
+ ---
252
+
253
+ ## Running the tests
254
+
255
+ ```bash
256
+ uv run pytest tests/unit/ # pure unit tests, no broker needed
257
+ uv run pytest tests/infrastructure/ -k "not rabbitmq" # FastAPI plugin tests only
258
+
259
+ # Full suite including RabbitMQ integration tests:
260
+ RABBITMQ_URL=amqp://admin:admin@localhost:5672/ uv run pytest
261
+ ```
262
+
263
+ ---
264
+
265
+ ## License
266
+
267
+ MIT
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "eventable"
3
+ version = "0.1.0"
4
+ description = "Domain Event Message Infrastructure"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Ioannis-Andreas Philippas", email = "ioannis.philippas@entaxilabs.gr" },
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "fastapi>=0.135.3",
12
+ "pika>=1.3.2",
13
+ "pydantic>=2.12.5",
14
+ "ruff>=0.15.9",
15
+ "starlette>=1.0.0",
16
+ ]
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.9.0,<0.10.0"]
20
+ build-backend = "uv_build"
21
+
22
+ #[tool.pyright]
23
+ #include = ["src"]
24
+ #venvPath = "."
25
+ #venv = ".venv"
26
+
27
+
28
+ [tool.ruff]
29
+ # Ignore missing docstring warnings (D1xx rules)
30
+ # F401: imported but unused (common with pytest fixtures)
31
+ # F811: redefinition of unused (common with pytest fixtures)
32
+ lint.ignore = ["D1", "F841", "PT019", "F401", "F811"]
33
+ src = ["src"]
34
+ line-length = 110
35
+ indent-width = 4
36
+ exclude = [
37
+ ".venv",
38
+ ".vscode",
39
+ "__pypackages__",
40
+ "node_modules",
41
+ "site-packages",
42
+ "venv",
43
+ ]
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "pika-stubs>=0.1.3",
48
+ "pytest>=9.0.2",
49
+ ]
@@ -0,0 +1,41 @@
1
+ import logging
2
+ import signal
3
+ from typing import Any, Callable
4
+
5
+ from eventable.domain_event import DomainEvent
6
+ from eventable.infrastructure.rabbitmq.event_publisher import RabbitMQPublisher
7
+ from eventable.infrastructure.rabbitmq.event_subscriber import RabbitMQSubscriber
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def rabbit_subscription(
14
+ consumer_name: str,
15
+ rabbit_url: str,
16
+ queue_name: str,
17
+ handlers_map: dict[type[DomainEvent], tuple[Callable, dict[str, Any]]],
18
+ ) -> None:
19
+ publisher = RabbitMQPublisher(
20
+ url=rabbit_url,
21
+ )
22
+ # event_collector = EventCollector()
23
+ publisher.connect()
24
+
25
+ subscriber = RabbitMQSubscriber(
26
+ url=rabbit_url,
27
+ queue_name=queue_name, # unique per bounded context
28
+ publisher=publisher,
29
+ )
30
+
31
+ # Register handlers for this service:
32
+ # subscriber.subscribe(OrderPlaced, handle_order_placed)
33
+ for event_name, (handler, kwargs) in handlers_map.items():
34
+ subscriber.subscribe(event_name, handler, **kwargs)
35
+
36
+ # Graceful shutdown
37
+ signal.signal(signal.SIGTERM, lambda s, f: subscriber.stop())
38
+ signal.signal(signal.SIGINT, lambda s, f: subscriber.stop())
39
+
40
+ logger.info(f"{consumer_name} Consumer starting...")
41
+ subscriber.start() #
@@ -0,0 +1,16 @@
1
+ from pydantic import BaseModel
2
+
3
+ from eventable.domain_event import DomainEvent
4
+
5
+
6
+ class AggregateRoot(BaseModel):
7
+ _domain_events: list[DomainEvent] = [] # private, excluded from serialization
8
+ model_config = {"arbitrary_types_allowed": True}
9
+
10
+ def add_domain_event(self, event: DomainEvent):
11
+ self._domain_events.append(event)
12
+
13
+ def pull_domain_events(self) -> list[DomainEvent]:
14
+ events = self._domain_events.copy()
15
+ self._domain_events.clear()
16
+ return events
@@ -0,0 +1,61 @@
1
+ from dataclasses import asdict, dataclass, fields, is_dataclass
2
+ from datetime import datetime
3
+ from decimal import Decimal
4
+ from typing import Any, Type
5
+ from uuid import UUID, uuid4
6
+
7
+ from eventable.value_object import ValueObject
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class DomainEvent:
12
+ event_id: UUID
13
+ occurred_at: datetime
14
+
15
+
16
+ def generate_event_id() -> UUID:
17
+ return uuid4()
18
+
19
+
20
+ def _convert(obj: Any) -> Any:
21
+ """Recursively convert non-JSON-serializable types."""
22
+ if isinstance(obj, UUID):
23
+ return str(obj)
24
+ if isinstance(obj, datetime):
25
+ return obj.isoformat()
26
+ if isinstance(obj, Decimal):
27
+ return str(obj)
28
+ if isinstance(obj, dict):
29
+ return {k: _convert(v) for k, v in obj.items()}
30
+ if isinstance(obj, list):
31
+ return [_convert(i) for i in obj]
32
+ if isinstance(obj, ValueObject):
33
+ return obj.get_value()
34
+ return obj
35
+
36
+
37
+ def serialize(event: Any) -> dict:
38
+ if is_dataclass(event) and not isinstance(event, type):
39
+ return _convert(asdict(event)) # 👈 pass through converter
40
+ raise TypeError(f"Cannot serialize event of type {type(event)}")
41
+
42
+
43
+ def deserialize(payload: dict, event_type: Type) -> Any:
44
+ if not is_dataclass(event_type):
45
+ raise TypeError(f"{event_type} is not a dataclass")
46
+
47
+ kwargs = {}
48
+ type_hints = {f.name: f.type for f in fields(event_type)}
49
+
50
+ for field_name, field_type in type_hints.items():
51
+ if field_name not in payload:
52
+ continue
53
+ value = payload[field_name]
54
+ if field_type in (UUID, "UUID"):
55
+ kwargs[field_name] = UUID(value)
56
+ elif field_type in (datetime, "datetime"):
57
+ kwargs[field_name] = datetime.fromisoformat(value)
58
+ else:
59
+ kwargs[field_name] = value
60
+
61
+ return event_type(**kwargs)
@@ -0,0 +1,8 @@
1
+ from eventable.event_managment.event_dispatcher import EventDispatcher
2
+
3
+
4
+ def register_handlers(dispatcher: EventDispatcher) -> None:
5
+ # Same bounded context handlers — register here:
6
+ # dispatcher.register(OrderPlaced, handle_order_placed_internally)
7
+ # dispatcher.register(PatientAdmited, handle_patient_admited)
8
+ ...
@@ -0,0 +1,16 @@
1
+ from eventable.aggregate_root import AggregateRoot
2
+ from eventable.domain_event import DomainEvent
3
+
4
+
5
+ class EventCollector:
6
+ def __init__(self):
7
+ self._aggregates: list[AggregateRoot] = []
8
+
9
+ def track(self, aggregate: AggregateRoot) -> None:
10
+ self._aggregates.append(aggregate)
11
+
12
+ def collect_events(self) -> list[DomainEvent]:
13
+ events = []
14
+ for aggregate in self._aggregates:
15
+ events.extend(aggregate.pull_domain_events())
16
+ return events
@@ -0,0 +1,38 @@
1
+ import logging
2
+ from typing import Callable, Type
3
+
4
+ from eventable.domain_event import DomainEvent
5
+ from eventable.event_managment.event_publisher import EventPublisher
6
+
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class EventDispatcher:
12
+ def __init__(self, publisher: EventPublisher):
13
+ self._publisher = publisher
14
+ self._handlers: dict[Type[DomainEvent], list[Callable]] = {}
15
+
16
+ def register(self, event_type: Type[DomainEvent], handler: Callable) -> None:
17
+ self._handlers.setdefault(event_type, []).append(handler)
18
+ logger.debug(
19
+ f"Registered handler '{handler.__name__}' for '{event_type.__name__}'"
20
+ )
21
+
22
+ def dispatch(self, event: DomainEvent) -> None:
23
+ event_type = type(event)
24
+
25
+ # 1. Same-context in-process handlers
26
+ for handler in self._handlers.get(event_type, []):
27
+ try:
28
+ handler(event)
29
+ except Exception:
30
+ logger.exception(
31
+ f"In-process handler '{handler.__name__}' failed for '{event_type.__name__}'"
32
+ )
33
+
34
+ # 2. Cross-context via broker
35
+ try:
36
+ self._publisher.publish(event)
37
+ except Exception:
38
+ logger.exception(f"Failed to publish '{event_type.__name__}' to broker")
@@ -0,0 +1,17 @@
1
+ import abc
2
+
3
+ from eventable.domain_event import DomainEvent
4
+
5
+
6
+ class EventPublisher(abc.ABC):
7
+ @abc.abstractmethod
8
+ def publish(self, event: DomainEvent) -> None:
9
+ raise NotImplementedError
10
+
11
+ @abc.abstractmethod
12
+ def connect(self) -> None:
13
+ raise NotImplementedError
14
+
15
+ @abc.abstractmethod
16
+ def disconnect(self) -> None:
17
+ raise NotImplementedError
@@ -0,0 +1,19 @@
1
+ import abc
2
+ from typing import Callable, Type
3
+
4
+ from eventable.domain_event import DomainEvent
5
+
6
+
7
+ class EventSubscriber(abc.ABC):
8
+ @abc.abstractmethod
9
+ def subscribe(
10
+ self,
11
+ event_type: Type[DomainEvent],
12
+ handler: Callable,
13
+ ) -> None: ...
14
+
15
+ @abc.abstractmethod
16
+ def start(self) -> None: ...
17
+
18
+ @abc.abstractmethod
19
+ def stop(self) -> None: ...
@@ -0,0 +1,41 @@
1
+ from contextlib import asynccontextmanager
2
+ import logging
3
+ from fastapi import FastAPI
4
+ from pydantic import BaseModel
5
+
6
+ from eventable.event_handlers import register_handlers
7
+ from eventable.event_managment.event_dispatcher import EventDispatcher
8
+ from eventable.infrastructure.rabbitmq.event_publisher import RabbitMQPublisher
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class EventableSettings(BaseModel):
14
+ rabbit_url: str
15
+ exchange_name: str = "domain_events"
16
+
17
+
18
+ class Eventable:
19
+ def __init__(self, settings: EventableSettings) -> None:
20
+ self.settings = settings
21
+ self.publisher: RabbitMQPublisher | None = None
22
+
23
+ async def startup(self, app: FastAPI):
24
+ self.publisher = RabbitMQPublisher(
25
+ url=self.settings.rabbit_url,
26
+ exchange_name=self.settings.exchange_name,
27
+ )
28
+ try:
29
+ self.publisher.connect()
30
+ except Exception:
31
+ logger.warning("RabbitMQ unavailable at startup — will retry on first publish")
32
+
33
+ dispatcher = EventDispatcher(self.publisher)
34
+ register_handlers(dispatcher)
35
+
36
+ app.state.publisher = self.publisher
37
+ app.state.dispatcher = dispatcher
38
+
39
+ async def shutdown(self):
40
+ if self.publisher:
41
+ self.publisher.disconnect()
@@ -0,0 +1,55 @@
1
+ # ============================================================
2
+ # STEP 10 — infrastructure/messaging/middleware.py
3
+ # Attaches a fresh EventCollector to every request.
4
+ # After the response is built, pulls all collected events
5
+ # from tracked aggregates and dispatches them.
6
+ # Registered in main.py via app.add_middleware().
7
+ # ============================================================
8
+ import logging
9
+
10
+ from eventable.event_managment.event_collector import EventCollector
11
+ from eventable.event_managment.event_dispatcher import EventDispatcher
12
+ from starlette.types import ASGIApp, Receive, Scope, Send
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class DomainEventMiddleware:
19
+ """
20
+ Pure ASGI middleware — avoids BaseHTTPMiddleware's exception swallowing.
21
+ Exceptions propagate cleanly to FastAPI's exception handlers.
22
+ """
23
+
24
+ def __init__(self, app: ASGIApp):
25
+ self.app = app
26
+
27
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
28
+ if scope["type"] != "http":
29
+ await self.app(scope, receive, send)
30
+ return
31
+
32
+ from starlette.requests import Request
33
+
34
+ request = Request(scope, receive, send)
35
+ request.state.collector = EventCollector()
36
+
37
+ # Track response status to avoid dispatching on errors
38
+ status_code = 500
39
+
40
+ async def send_wrapper(message):
41
+ nonlocal status_code
42
+ if message["type"] == "http.response.start":
43
+ status_code = message["status"]
44
+ await send(message)
45
+
46
+ # Let exceptions propagate naturally — no wrapping
47
+ await self.app(scope, receive, send_wrapper)
48
+
49
+ if status_code < 400:
50
+ dispatcher: EventDispatcher = scope["app"].state.dispatcher
51
+ for event in request.state.collector.collect_events():
52
+ try:
53
+ dispatcher.dispatch(event)
54
+ except Exception:
55
+ logger.exception(f"Failed to dispatch '{type(event).__name__}'")
@@ -0,0 +1,70 @@
1
+ from datetime import datetime, timezone
2
+ import logging
3
+ import json
4
+ import threading
5
+
6
+ import pika
7
+ from pika.exchange_type import ExchangeType
8
+ from pika import DeliveryMode
9
+
10
+ from eventable.domain_event import DomainEvent, serialize
11
+ from eventable.event_managment.event_publisher import EventPublisher
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class RabbitMQPublisher(EventPublisher):
17
+ def __init__(self, url: str, exchange_name: str = "domain_events"):
18
+ self._url = url
19
+ self._exchange_name = exchange_name
20
+ self._local = threading.local()
21
+
22
+ def connect(self) -> None:
23
+ self._get_channel()
24
+ logger.info("RabbitMQ publisher connected")
25
+
26
+ def disconnect(self) -> None:
27
+ conn = getattr(self._local, "connection", None)
28
+ if conn and not conn.is_closed:
29
+ conn.close()
30
+ logger.info("RabbitMQ publisher disconnected")
31
+
32
+ def publish(self, event: DomainEvent) -> None:
33
+ channel = self._get_channel()
34
+ routing_key = type(event).__name__
35
+
36
+ payload = {
37
+ "event_type": routing_key,
38
+ "occurred_at": datetime.now(timezone.utc).isoformat(),
39
+ "payload": serialize(event),
40
+ }
41
+
42
+ channel.basic_publish(
43
+ exchange=self._exchange_name,
44
+ routing_key=routing_key,
45
+ body=json.dumps(payload).encode(),
46
+ properties=pika.BasicProperties(
47
+ content_type="application/json",
48
+ delivery_mode=DeliveryMode.Persistent,
49
+ ),
50
+ )
51
+ logger.debug(f"Published: {routing_key}")
52
+
53
+ def _get_channel(self):
54
+ conn = getattr(self._local, "connection", None)
55
+ chan = getattr(self._local, "channel", None)
56
+
57
+ if not conn or conn.is_closed or not chan or not chan.is_open:
58
+ params = pika.URLParameters(self._url)
59
+ params.heartbeat = 60
60
+ params.blocked_connection_timeout = 300
61
+ self._local.connection = pika.BlockingConnection(params)
62
+ self._local.channel = self._local.connection.channel()
63
+ self._local.channel.exchange_declare(
64
+ exchange=self._exchange_name,
65
+ exchange_type=ExchangeType.topic,
66
+ durable=True,
67
+ )
68
+ logger.info(f"RabbitMQ channel opened on thread: {threading.current_thread().name}")
69
+
70
+ return self._local.channel
@@ -0,0 +1,109 @@
1
+ import json
2
+ import logging
3
+ from typing import Callable, Type
4
+ from functools import partial
5
+
6
+ import pika
7
+ from pika.exchange_type import ExchangeType
8
+
9
+ from eventable.domain_event import DomainEvent, deserialize
10
+ from eventable.event_managment.event_publisher import EventPublisher
11
+ from eventable.event_managment.event_subscriber import EventSubscriber
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Internal registry entry — keeps handler and its UoW class together
17
+ type HandlerEntry = tuple[Type[DomainEvent], Callable]
18
+
19
+
20
+ class RabbitMQSubscriber(EventSubscriber):
21
+ def __init__(
22
+ self,
23
+ url: str,
24
+ queue_name: str,
25
+ publisher: EventPublisher,
26
+ exchange_name: str = "domain_events",
27
+ ):
28
+ self._url = url
29
+ self._queue_name = queue_name
30
+ self._publisher = publisher
31
+ self._exchange_name = exchange_name
32
+ self._handlers: dict[str, HandlerEntry] = {}
33
+ self._connection: pika.BlockingConnection | None = None
34
+ self._channel = None
35
+
36
+ def subscribe(self, event_type: Type[DomainEvent], handler: Callable, **kwargs) -> None:
37
+ routing_key = event_type.__name__
38
+
39
+ handler_name = handler.__name__
40
+ if kwargs:
41
+ handler = partial(handler, **kwargs)
42
+
43
+ self._handlers[routing_key] = (event_type, handler)
44
+ logger.debug(f"Subscribed '{handler_name}' to '{routing_key}'")
45
+
46
+ def start(self) -> None:
47
+ self._connect()
48
+ logger.info(f"Consumer listening on queue: '{self._queue_name}'")
49
+ if not self._channel:
50
+ raise
51
+ self._channel.start_consuming()
52
+
53
+ def stop(self) -> None:
54
+ if self._channel and self._channel.is_open:
55
+ self._channel.stop_consuming()
56
+ if self._connection and not self._connection.is_closed:
57
+ self._connection.close()
58
+ logger.info("RabbitMQ subscriber stopped")
59
+
60
+ def _connect(self):
61
+ params = pika.URLParameters(self._url)
62
+ params.heartbeat = 60
63
+ params.blocked_connection_timeout = 300
64
+
65
+ self._connection = pika.BlockingConnection(params)
66
+ self._channel = self._connection.channel()
67
+
68
+ self._channel.exchange_declare(
69
+ exchange=self._exchange_name,
70
+ exchange_type=ExchangeType.topic,
71
+ durable=True,
72
+ )
73
+ self._channel.queue_declare(queue=self._queue_name, durable=True)
74
+
75
+ for routing_key in self._handlers:
76
+ self._channel.queue_bind(
77
+ queue=self._queue_name,
78
+ exchange=self._exchange_name,
79
+ routing_key=routing_key,
80
+ )
81
+ logger.info(f"Bound '{self._queue_name}' → '{routing_key}'")
82
+
83
+ self._channel.basic_qos(prefetch_count=1)
84
+ self._channel.basic_consume(
85
+ queue=self._queue_name,
86
+ on_message_callback=self._on_message,
87
+ )
88
+
89
+ def _on_message(self, channel, method, properties, body: bytes):
90
+ routing_key = method.routing_key
91
+
92
+ if routing_key not in self._handlers:
93
+ logger.warning(f"No handler for routing key: '{routing_key}'")
94
+ channel.basic_nack(method.delivery_tag, requeue=False)
95
+ return
96
+
97
+ event_type, handler = self._handlers[routing_key] # 👈 unpack uow_class
98
+
99
+ try:
100
+ raw = json.loads(body)
101
+ event = deserialize(raw["payload"], event_type)
102
+
103
+ handler(event)
104
+
105
+ channel.basic_ack(method.delivery_tag)
106
+
107
+ except Exception:
108
+ logger.exception(f"Handler failed for '{routing_key}' — nacking to DLQ")
109
+ channel.basic_nack(method.delivery_tag, requeue=False)
File without changes
@@ -0,0 +1,10 @@
1
+ from typing import Generic, Protocol, TypeVar, runtime_checkable
2
+
3
+ T = TypeVar("T")
4
+
5
+
6
+ @runtime_checkable
7
+ class ValueObject(Protocol, Generic[T]):
8
+ value: T
9
+
10
+ def get_value(self) -> T: ...