# python-cqrs
> Документация по библиотеке python-cqrs. Паттерны CQRS, Saga, Mediator и Event Driven на Python.
python-cqrs is a Python framework for CQRS, Saga, Mediator, and event-driven
architectures. Use these docs for bootstrap, request/event handlers, transaction
outbox, streaming, FastAPI/FastStream integrations, and distributed sagas.
The full text of every current page is in https://mkdocs.python-cqrs.dev/llms-full.txt
# Getting started
# Python CQRS
Event-Driven Architecture Framework for Distributed Systems
[🐙 Star if cool ⭐ ✨ ✨](https://github.com/vadikko2/python-cqrs)
[📦 PyPI](https://pypi.org/project/python-cqrs/) [📊 Downloads](https://clickpy.clickhouse.com/dashboard/python-cqrs)
Documentation for python-cqrs 5.x
You are viewing **5.x** docs (dataclass defaults, optional Pydantic/SQLAlchemy, optional SQLModel storage since 5.1, new package layout, `SagaMediator.execute`).
Migrating from 4.x? Follow the [4→5 migration guide](https://mkdocs.python-cqrs.dev/latest/migration/4-to-5/index.md). Use the version selector for frozen **4.0** docs.
______________________________________________________________________
## Core Features
- **Bootstrap**
Quick project setup and configuration with automatic DI container setup.
[Read More](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md)
- **Request Handlers**
Handle commands and queries with full type safety and async support.
[Read More](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md)
- **Saga Pattern**
Orchestrated Saga for distributed transactions with automatic compensation.
[Read More](https://mkdocs.python-cqrs.dev/latest/saga/index.md)
- **Event Handling**
Process domain events with parallel processing and runtime execution.
[Read More](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md)
- **Transaction Outbox**
Guaranteed event delivery with at-least-once semantics.
[Read More](https://mkdocs.python-cqrs.dev/latest/outbox/index.md)
- **Chain of Responsibility**
Sequential request processing with flexible handler chaining.
[Read More](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/index.md)
- **Streaming**
Incremental processing with real-time progress updates via SSE.
[Read More](https://mkdocs.python-cqrs.dev/latest/stream_handling/index.md)
- **Integrations**
FastAPI and FastStream integrations out of the box.
[Read More](https://mkdocs.python-cqrs.dev/latest/fastapi/index.md)
- **Mermaid Diagrams**
Visualize architecture patterns and flows with interactive Mermaid diagrams.
[Read More](https://mkdocs.python-cqrs.dev/latest/mermaid/index.md)
______________________________________________________________________
## What is it?
**Python CQRS** is a framework for implementing the CQRS (Command Query Responsibility Segregation) pattern in Python applications. It helps separate read and write operations, improving scalability, performance, and code maintainability.
**Key Highlights:**
- **Performance** — Separation of commands and queries, parallel event processing
- **Reliability** — Transaction Outbox for guaranteed event delivery, Saga with compensation support and eventual consistency
- **Flexible Types** — Easy integration with any type: [Pydantic, dataclasses, msgspec, attrs, TypedDict and more](https://mkdocs.python-cqrs.dev/latest/request_response_types/index.md)
- **Ready Integrations** — FastAPI and FastStream out of the box
- **Simple Setup** — Bootstrap for quick configuration
- **Proven Patterns** — CQRS, Saga, Outbox and more to keep services decoupled and maintainable
______________________________________________________________________
## Project status
| Group | Badges |
| ------------------------- | ------ |
| Python version & PyPI | |
| Downloads | |
| Quality & CI | |
| Documentation & community | |
______________________________________________________________________
## Installation
Install Python CQRS using pip or uv:
**Using pip:**
```bash
pip install python-cqrs
```
**Using uv:**
```bash
uv pip install python-cqrs
```
Requirements
Python 3.10+ (tested on 3.10–3.13)
______________________________________________________________________
## Quick Start
```python
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
# Define command, response and handler
class CreateUserCommand(cqrs.Request):
email: str
name: str
class CreateUserResponse(cqrs.Response):
user_id: str
class CreateUserHandler(cqrs.RequestHandler[CreateUserCommand, CreateUserResponse]):
async def handle(self, request: CreateUserCommand) -> CreateUserResponse:
# Your business logic here
user_id = f"user_{request.email}"
return CreateUserResponse(user_id=user_id)
# Bootstrap and use
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=lambda m: m.bind(CreateUserCommand, CreateUserHandler),
)
result = await mediator.send(CreateUserCommand(email="user@example.com", name="John"))
```
See [Bootstrap](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md) for detailed setup instructions.
# Installation
**Python 3.10+** is required.
```bash
pip install python-cqrs
```
## Extras (5.0+)
| Extra | Provides |
| ------------ | ------------------------------------------------------------------ |
| `pydantic` | `PydanticRequest`, `PydanticResponse`, `Pydantic*Event` |
| `sqlalchemy` | SQLAlchemy outbox + saga storage |
| `sqlmodel` | SQLModel outbox + saga storage (same behavior as SQLAlchemy; 5.1+) |
| `kafka` | Kafka broker (`aiokafka`) |
| `rabbit` | AMQP broker |
| `protobuf` | Protobuf serializers |
| `aiobreaker` | Circuit breaker helpers |
| `examples` | FastAPI / FastStream sample stack |
```bash
pip install "python-cqrs[pydantic,sqlalchemy]"
pip install "python-cqrs[sqlmodel]"
pip install "python-cqrs[kafka]"
```
Dataclass-based `Request` / `Response` / `Event` types are the defaults. See [Migration 4→5](https://mkdocs.python-cqrs.dev/latest/migration/4-to-5/index.md) for the full breaking-change list. Outbox and saga storage with SQLAlchemy or SQLModel is described in [ORM](https://mkdocs.python-cqrs.dev/latest/orm/index.md).
# Migration guide: 4.x → 5.0
This page covers breaking changes in **python-cqrs 5.0**. Discussion: [GitHub #57](https://github.com/vadikko2/python-cqrs/discussions/57).
## Support policy
| Line | What you get |
| ------------------------------------------- | ------------------------------------------- |
| **4.x** (`pip install "python-cqrs>=4,<5"`) | Bug fixes / security (~12 months after 5.0) |
| **5.x** | Features + breaking changes |
Docs: use the version selector for [4.0](https://mkdocs.python-cqrs.dev/latest/index.md) vs this 5.0 site.
## Optional extras
Pydantic and SQLAlchemy are **no longer** core dependencies.
```bash
pip install python-cqrs # dataclasses defaults
pip install "python-cqrs[pydantic]" # Pydantic* types
pip install "python-cqrs[sqlalchemy]" # Outbox / Saga SQLAlchemy storage
pip install "python-cqrs[pydantic,sqlalchemy]" # ≈ previous default stack
```
Default aliases `Request`, `Response`, `Event`, `DomainEvent`, `NotificationEvent` point at **`DC*`** dataclasses. Use `PydanticRequest` / `PydanticEvent` / … from `cqrs.models.pydantic` when you need validation.
## Import path table (hard break — no shims)
| 4.x | 5.0 |
| --------------------------------------------------------------- | -------------------------------------------------------------- |
| `cqrs.requests.bootstrap` | `cqrs.bootstrap.requests` |
| `cqrs.events.bootstrap` | `cqrs.bootstrap.events` |
| `cqrs.saga.bootstrap` | `cqrs.bootstrap.saga` |
| `cqrs.mediator` | `cqrs.mediators` (`.request`, `.event`, `.streaming`, `.saga`) |
| `cqrs.requests.request_handler` | `cqrs.handlers.request` |
| `cqrs.requests.cor_request_handler` | `cqrs.handlers.cor` |
| `cqrs.events.event_handler` | `cqrs.handlers.event` |
| `cqrs.saga.step` | `cqrs.handlers.saga` |
| `cqrs.requests.request` / `cqrs.response` / `cqrs.events.event` | `cqrs.models.request` / `.response` / `.event` |
| Pydantic classes | `cqrs.models.pydantic` |
| `cqrs.requests.map` / `cqrs.events.map` / `cqrs.outbox.map` | `cqrs.mapping.requests` / `.events` / `.outbox` |
| `cqrs.requests.mermaid` / `cqrs.saga.mermaid` | `cqrs.mermaid.cor` / `.saga` |
| `cqrs.producer.EventProducer` | `cqrs.message_brokers.producer.EventProducer` |
Root `import cqrs` still re-exports the stable public surface (mediators, handlers, DC models, maps, mermaid, `EventProducer`).
## SagaMediator.stream → execute
```python
# 4.x
async for step in mediator.stream(context, saga_id=saga_id):
...
# 5.0
async for step in mediator.execute(context, saga_id=saga_id):
...
```
`StreamingRequestMediator.stream` is **unchanged**.
## Optional `events` on handlers
Empty `events` / default `()` behavior from 4.15 remains; you do not need to declare an empty events property.
## Checklist
1. Pin or upgrade: `"python-cqrs>=5"` vs `"python-cqrs>=4,<5"`.
1. Install extras you still need (`pydantic`, `sqlalchemy`, `kafka`, …).
1. Switch defaults to DC\* or import `Pydantic*` explicitly.
1. Rewrite imports using the table above.
1. Rename `SagaMediator.stream` → `execute`.
1. Re-run tests / type check.
# Bootstrap
# Bootstrap
## Overview
- **Request Mediator**
Standard mediator for commands and queries with automatic handler resolution.
[Read More](https://mkdocs.python-cqrs.dev/latest/bootstrap/request_mediator/index.md)
- **Streaming Request Mediator**
For incremental processing and Server-Sent Events (SSE) support.
[Read More](https://mkdocs.python-cqrs.dev/latest/bootstrap/streaming_mediator/index.md)
- **Event Mediator**
For processing events from message brokers like Kafka and RabbitMQ.
[Read More](https://mkdocs.python-cqrs.dev/latest/bootstrap/event_mediator/index.md)
- **Saga Mediator**
Orchestrated sagas with step streaming, storage, and recovery.
[Read More](https://mkdocs.python-cqrs.dev/latest/bootstrap/saga_mediator/index.md)
- **Message Brokers**
Configure Kafka, RabbitMQ, and custom brokers for event publishing.
[Read More](https://mkdocs.python-cqrs.dev/latest/bootstrap/message_brokers/index.md)
- **Middlewares**
Request interception and modification with custom middleware support.
[Read More](https://mkdocs.python-cqrs.dev/latest/bootstrap/middlewares/index.md)
- **DI Containers**
Dependency injection configuration and container setup.
[Read More](https://mkdocs.python-cqrs.dev/latest/bootstrap/di_containers/index.md)
- **Advanced Configuration**
Combining all options and manual setup for complex scenarios.
[Read More](https://mkdocs.python-cqrs.dev/latest/bootstrap/advanced/index.md)
The `bootstrap` utilities simplify the initial configuration of your CQRS application. They automatically set up:
- **Dependency Injection Container** — Resolves handlers and their dependencies (see [Dependency Injection](https://mkdocs.python-cqrs.dev/latest/di/index.md))
- **Request Mapping** — Maps commands and queries to their handlers (see [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md))
- **Event Mapping** — Maps domain events to their handlers (see [Event Handling](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md))
- **Saga Mapping** — Maps saga context types to saga classes (see [Saga Pattern](https://mkdocs.python-cqrs.dev/latest/saga/index.md))
- **Message Broker** — Configures event publishing (see [Event Producing](https://mkdocs.python-cqrs.dev/latest/event_producing/index.md))
- **Middlewares** — Adds logging and custom middlewares
- **Event Processing** — Configures parallel event processing
Getting Started
If you're new to `python-cqrs`, start here! Bootstrap is the foundation for all other features. After configuring bootstrap, proceed to [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) to learn how to create command and query handlers.
Navigation
Use the navigation menu on the left to explore different mediator types and configuration options. Each section covers a specific aspect of bootstrap configuration.
## Mediator Types
The `python-cqrs` package provides four types of mediators:
| Mediator Type | Use Case | Bootstrap Function |
| ------------------------------ | --------------------------------------------------- | ----------------------------------------------- |
| **`RequestMediator`** | Standard commands and queries | `cqrs.bootstrap.requests.bootstrap()` |
| **`StreamingRequestMediator`** | Streaming requests with incremental results | `cqrs.bootstrap.requests.bootstrap_streaming()` |
| **`EventMediator`** | Processing events from message brokers | `cqrs.bootstrap.events.bootstrap()` |
| **`SagaMediator`** | Orchestrated sagas with step streaming and recovery | `cqrs.bootstrap.saga.bootstrap()` |
Mediator Comparison
Each mediator type has its own bootstrap function and configuration options. Choose based on your use case:
- **RequestMediator**: Most common, handles standard CQRS operations
- **StreamingRequestMediator**: For real-time progress updates (SSE, WebSockets)
- **EventMediator**: For consuming events from Kafka/RabbitMQ
- **SagaMediator**: For distributed transactions with steps and compensation
**When to use each mediator?**
- **RequestMediator**: Use for standard HTTP API endpoints (GET, POST, PUT, DELETE)
- **StreamingRequestMediator**: Use when you need to stream results back to clients (file processing, batch operations)
- **EventMediator**: Use in FastStream consumers to process events from message brokers
- **SagaMediator**: Use when coordinating multiple steps across services with automatic compensation on failure
## Quick Navigation
- **[Request Mediator](https://mkdocs.python-cqrs.dev/latest/bootstrap/request_mediator/index.md)** — Standard mediator for commands and queries
- **[Streaming Request Mediator](https://mkdocs.python-cqrs.dev/latest/bootstrap/streaming_mediator/index.md)** — For incremental processing and SSE
- **[Event Mediator](https://mkdocs.python-cqrs.dev/latest/bootstrap/event_mediator/index.md)** — For processing events from message brokers
- **[Saga Mediator](https://mkdocs.python-cqrs.dev/latest/bootstrap/saga_mediator/index.md)** — Orchestrated sagas with storage and recovery
- **[Message Brokers](https://mkdocs.python-cqrs.dev/latest/bootstrap/message_brokers/index.md)** — Kafka, RabbitMQ, and custom brokers
- **[Middlewares](https://mkdocs.python-cqrs.dev/latest/bootstrap/middlewares/index.md)** — Request interception and modification
- **[DI Containers](https://mkdocs.python-cqrs.dev/latest/bootstrap/di_containers/index.md)** — Dependency injection configuration
- **[Advanced Configuration](https://mkdocs.python-cqrs.dev/latest/bootstrap/advanced/index.md)** — Combining all options and manual setup
# Advanced
- **Back to Bootstrap Overview**
Return to the Bootstrap overview page with all configuration options.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md)
- **Commands / Requests Handling**
Core concepts for commands, queries, and request handlers.
[Read More](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md)
______________________________________________________________________
### Combining All Options
Here's a complete example combining all configuration options:
```python
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
from cqrs.message_brokers import kafka
from cqrs.adapters.kafka import KafkaProducerAdapter
from cqrs.middlewares import base
# DI Container setup
container = di.Container()
# ... bind dependencies ...
# Mappers
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(CreateUserCommand, CreateUserCommandHandler)
def queries_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(GetUserQuery, GetUserQueryHandler)
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(UserCreatedEvent, UserCreatedEventHandler)
# Custom middleware
class CustomMiddleware(base.Middleware):
async def __call__(self, request: cqrs.Request, handle):
# Custom logic
return await handle(request)
# Message broker
kafka_producer = KafkaProducerAdapter(
bootstrap_servers=["localhost:9092"],
)
kafka_broker = kafka.KafkaMessageBroker(producer=kafka_producer)
# Startup callbacks
def init_database():
print("Database initialized")
def init_cache():
print("Cache initialized")
# Complete bootstrap
mediator = bootstrap.bootstrap(
di_container=container,
commands_mapper=commands_mapper,
queries_mapper=queries_mapper,
domain_events_mapper=events_mapper,
message_broker=kafka_broker,
middlewares=[CustomMiddleware()],
on_startup=[init_database, init_cache],
max_concurrent_event_handlers=5,
concurrent_event_handle_enable=True,
)
```
### Manual Setup (Advanced)
For advanced use cases, you can manually set up mediators and emitters:
```python
from cqrs.bootstrap import requests as bootstrap
from cqrs.events import EventEmitter
# Manually create event emitter
event_emitter = bootstrap.setup_event_emitter(
container=container,
domain_events_mapper=events_mapper,
message_broker=kafka_broker,
)
# Manually create mediator
mediator = bootstrap.setup_mediator(
event_emitter=event_emitter,
container=container,
middlewares=[CustomMiddleware()],
commands_mapper=commands_mapper,
queries_mapper=queries_mapper,
max_concurrent_event_handlers=5,
concurrent_event_handle_enable=True,
)
```
# Di Containers
- **Back to Bootstrap Overview**
Return to the Bootstrap overview page with all configuration options.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md)
- **Dependency Injection**
Container setup, scopes, and resolving handlers and dependencies.
[Read More](https://mkdocs.python-cqrs.dev/latest/di/index.md)
______________________________________________________________________
## Overview
The bootstrap functions support multiple DI container implementations.
| Container | Library | Default | Best For |
| ----------------------- | ------------------------------ | ------- | ------------------------------------------ |
| **di.Container** | `di` | ✅ Yes | Most applications |
| **DishkaCQRSContainer** | dishka (`python-cqrs[dishka]`) | ❌ No | Apps already using dishka / request scopes |
| **CQRSContainer** | `dependency-injector` | ❌ No | Large, complex applications |
Container Choice
Use `di.Container` for most applications. Use dishka when you already rely on it for request scopes. Use `dependency-injector` only if you need advanced features like configuration management or nested containers — it does not implement CQRS `SupportsScope` (see [Scoped Dependencies](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/containers/index.md)).
Scoped UoW
Pass `scope_strategy=ScopeStrategy.SEND` to bootstrap so generator providers finalize after `handle`; the default `ScopeStrategy.NONE` opens no scopes. SEND infers sequential events (`concurrent_event_handle_enable=None` → `False`) and shares the same UoW with fallback. A plain container is enough — mediators wrap it internally. Details: [Scoped Dependencies](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/index.md).
### di.Container
The `di` library is the default and recommended DI container:
```python
import abc
import di
from di import dependent
class ServiceProtocol(abc.ABC):
@abc.abstractmethod
async def do_work(self) -> None:
pass
class ServiceImpl(ServiceProtocol):
async def do_work(self) -> None:
print("Working...")
# Setup DI container
container = di.Container()
container.bind(
di.bind_by_type(
dependent.Dependent(ServiceImpl, scope="request"),
ServiceProtocol,
)
)
mediator = bootstrap.bootstrap(
di_container=container,
commands_mapper=commands_mapper,
)
```
### CQRSContainer (dependency-injector)
The `DependencyInjectorCQRSContainer` adapter allows using the `dependency-injector` library:
```python
from dependency_injector import containers, providers
from cqrs.container.dependency_injector import DependencyInjectorCQRSContainer
class ApplicationContainer(containers.DeclarativeContainer):
# Define your services
service = providers.Factory(ServiceImpl)
# Create CQRS container adapter and attach the DI container
cqrs_container = DependencyInjectorCQRSContainer()
cqrs_container.attach_external_container(ApplicationContainer())
mediator = bootstrap.bootstrap(
di_container=cqrs_container,
commands_mapper=commands_mapper,
)
```
### DishkaCQRSContainer
Install with `pip install python-cqrs[dishka]`. Register handlers as dishka providers:
```python
from dishka import Provider, Scope, make_async_container, provide
from cqrs.container.dishka import DishkaCQRSContainer
class AppProvider(Provider):
@provide(scope=Scope.REQUEST)
def service(self) -> ServiceImpl:
return ServiceImpl()
handler = provide(MyHandler, scope=Scope.REQUEST)
mediator = bootstrap.bootstrap(
di_container=DishkaCQRSContainer(make_async_container(AppProvider())),
commands_mapper=commands_mapper,
)
```
See [Scoped Dependencies — Containers](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/containers/index.md) for full details.
# Event Mediator
- **Back to Bootstrap Overview**
Return to the Bootstrap overview page with all configuration options.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md)
- **Events Handling**
Event types, flow, parallel processing, and handlers.
[Read More](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md)
______________________________________________________________________
## Overview
The `EventMediator` processes events received from message brokers (like Kafka, RabbitMQ). It's used in event consumers to handle incoming events.
### Basic Configuration
```python
from cqrs.bootstrap import events as bootstrap
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(
cqrs.NotificationEvent[UserCreatedPayload],
UserCreatedEventHandler
)
event_mediator = bootstrap.bootstrap(
di_container=di.Container(),
events_mapper=events_mapper,
)
```
### With Custom Middlewares
```python
from cqrs.middlewares import base
class EventLoggingMiddleware(base.Middleware):
async def __call__(self, request: cqrs.Request, handle):
print(f"Processing event: {type(request).__name__}")
result = await handle(request)
print(f"Event processed: {type(request).__name__}")
return result
event_mediator = bootstrap.bootstrap(
di_container=di.Container(),
events_mapper=events_mapper,
middlewares=[EventLoggingMiddleware()],
)
```
### With On Startup Callbacks
```python
def setup_event_store():
# Initialize event store connections
print("Event store initialized")
event_mediator = bootstrap.bootstrap(
di_container=di.Container(),
events_mapper=events_mapper,
on_startup=[setup_event_store],
)
```
### Complete Example with FastStream
```python
import di
import cqrs
from cqrs.bootstrap import events as bootstrap
from faststream import kafka
import faststream
class UserCreatedPayload(cqrs.Response):
user_id: str
email: str
class UserCreatedEventHandler(
cqrs.EventHandler[cqrs.NotificationEvent[UserCreatedPayload]]
):
async def handle(
self, event: cqrs.NotificationEvent[UserCreatedPayload]
) -> None:
print(f"User {event.payload.user_id} created")
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(
cqrs.NotificationEvent[UserCreatedPayload],
UserCreatedEventHandler
)
def mediator_factory() -> cqrs.EventMediator:
return bootstrap.bootstrap(
di_container=di.Container(),
events_mapper=events_mapper,
)
broker = kafka.KafkaBroker(bootstrap_servers=["localhost:9092"])
app = faststream.FastStream(broker)
@broker.subscriber("user_events")
async def handle_user_event(
event: cqrs.NotificationEvent[UserCreatedPayload],
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
await mediator.send(event)
```
# Message Brokers
- **Back to Bootstrap Overview**
Return to the Bootstrap overview page with all configuration options.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md)
- **Event Producing**
Publishing events to Kafka, RabbitMQ, and custom brokers.
[Read More](https://mkdocs.python-cqrs.dev/latest/event_producing/index.md)
______________________________________________________________________
## Overview
Message brokers are used to publish `NotificationEvent` and `ECSTEvent` events to external systems. The `python-cqrs` package supports multiple message broker implementations.
| Broker Type | Use Case | Production Ready |
| ------------------------ | ------------------------------------ | ---------------- |
| **DevnullMessageBroker** | Testing, development | ❌ No |
| **KafkaMessageBroker** | High-throughput, distributed systems | ✅ Yes |
| **AMQPMessageBroker** | RabbitMQ, traditional message queues | ✅ Yes |
**Choosing a Message Broker**
- **DevnullMessageBroker**: Use for testing and development when you don't need actual message publishing
- **KafkaMessageBroker**: Use for high-throughput, distributed systems with strong ordering guarantees
- **AMQPMessageBroker**: Use for traditional message queue patterns, RabbitMQ integration
### DevnullMessageBroker
The `DevnullMessageBroker` is a no-op broker used for testing. It doesn't actually send messages anywhere but logs warnings.
Development Only
Never use `DevnullMessageBroker` in production. It's designed for testing and development only.
```python
from cqrs.message_brokers import devnull
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
message_broker=devnull.DevnullMessageBroker(),
)
```
### KafkaMessageBroker
The `KafkaMessageBroker` publishes events to Apache Kafka topics.
```python
from cqrs.message_brokers import kafka
from cqrs.adapters.kafka import KafkaProducerAdapter
# Create Kafka producer adapter
kafka_producer = KafkaProducerAdapter(
bootstrap_servers=["localhost:9092"],
client_id="my-app",
acks="all", # Wait for all replicas
enable_idempotence=True,
)
# Create message broker
kafka_broker = kafka.KafkaMessageBroker(
producer=kafka_producer,
aiokafka_log_level="ERROR", # Suppress verbose logging
)
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
message_broker=kafka_broker,
)
```
### AMQPMessageBroker
The `AMQPMessageBroker` publishes events to RabbitMQ or other AMQP-compatible brokers.
```python
from cqrs.message_brokers import amqp
from cqrs.adapters.amqp import AMQPPublisherAdapter
import aio_pika
# Create AMQP publisher
amqp_publisher = AMQPPublisherAdapter(
dsn="amqp://user:password@localhost/",
)
# Create message broker
amqp_broker = amqp.AMQPMessageBroker(
publisher=amqp_publisher,
exchange_name="events",
pika_log_level="ERROR",
)
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
message_broker=amqp_broker,
)
```
### Custom Message Broker
You can create custom message brokers by implementing the `MessageBroker` protocol:
```python
from cqrs.message_brokers import protocol
class CustomMessageBroker(protocol.MessageBroker):
async def send_message(self, message: protocol.Message) -> None:
# Custom implementation
print(f"Sending {message.message_name} to {message.topic}")
# Send to your custom broker
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
message_broker=CustomMessageBroker(),
)
```
### Event serializer (optional)
`bootstrap(..., serializer=...)` forwards a codec into `EventEmitter`. The default is JSON. Pass `OutboxedEventMap.as_serializer()` when some notification events use Protobuf — see [Protobuf Integration](https://mkdocs.python-cqrs.dev/latest/protobuf/index.md).
# Middlewares
- **Back to Bootstrap Overview**
Return to the Bootstrap overview page with all configuration options.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md)
- **Commands / Requests Handling**
Request pipeline and handler execution where middlewares apply.
[Read More](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md)
______________________________________________________________________
## Overview
Middlewares allow you to intercept and modify request processing. The `python-cqrs` package includes a built-in `LoggingMiddleware` and allows you to create custom middlewares.
### LoggingMiddleware
The `LoggingMiddleware` is automatically added to all mediators. It logs request and response details.
```python
# LoggingMiddleware is added automatically
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
# LoggingMiddleware is added automatically
)
```
### Custom Middleware
Create custom middlewares by implementing the `Middleware` protocol:
```python
from cqrs.middlewares import base
import time
class TimingMiddleware(base.Middleware):
async def __call__(self, request: cqrs.Request, handle):
start_time = time.time()
result = await handle(request)
elapsed = time.time() - start_time
print(f"Request {type(request).__name__} took {elapsed:.2f}s")
return result
class ValidationMiddleware(base.Middleware):
async def __call__(self, request: cqrs.Request, handle):
# Validate request
if hasattr(request, 'validate'):
request.validate()
result = await handle(request)
return result
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
middlewares=[TimingMiddleware(), ValidationMiddleware()],
)
```
### Middleware Order
Middlewares are executed in the order they are added, with the last middleware wrapping the handler first:
| Order | Middleware | Execution |
| ----- | ---------------------- | -------------------------- |
| 1 | `TimingMiddleware` | Executes first (outermost) |
| 2 | `ValidationMiddleware` | Executes second |
| 3 | Handler | Executes last (innermost) |
```python
# Execution order: TimingMiddleware -> ValidationMiddleware -> Handler
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
middlewares=[
TimingMiddleware(), # Executes first (outermost)
ValidationMiddleware(), # Executes second
],
)
```
Middleware Execution
Middlewares wrap handlers in reverse order. The first middleware in the list is the outermost wrapper, executing before all others.
# Request Mediator
- **Back to Bootstrap Overview**
Return to the Bootstrap overview page with all configuration options.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md)
- **Commands / Requests Handling**
Commands, queries, handlers, and request/response mapping.
[Read More](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md)
## Overview
The `RequestMediator` is the standard mediator for handling commands and queries. It processes requests synchronously and emits events after handler execution.
### Basic Configuration
```python
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(CreateUserCommand, CreateUserCommandHandler)
def queries_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(GetUserQuery, GetUserQueryHandler)
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(
cqrs.DomainEvent[UserCreatedPayload],
UserCreatedEventHandler
)
# Basic bootstrap with di.Container
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
queries_mapper=queries_mapper,
domain_events_mapper=events_mapper,
)
```
`RequestMediator(..., scope_strategy=ScopeStrategy.SEND)` and `setup_mediator(..., scope_strategy=ScopeStrategy.SEND)` work out of the box: `concurrent_event_handle_enable` defaults to `None` (`False` under SEND, sequential BFS). Do not pass `concurrent_event_handle_enable=True` with SEND — that raises `ValueError`. `requests.bootstrap` still defaults concurrent processing to `False`. For a shared UoW between command, fallback, and domain events, pass `scope_strategy=ScopeStrategy.SEND` (see [Scope Strategies](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/strategies/index.md)).
```python
from cqrs import ScopeStrategy
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
scope_strategy=ScopeStrategy.SEND, # sequential events; no concurrent=False needed
)
```
### With Message Broker
```python
from cqrs.message_brokers import devnull, kafka
# Using DevnullMessageBroker (for testing)
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
message_broker=devnull.DevnullMessageBroker(),
)
# Using KafkaMessageBroker
from cqrs.adapters.kafka import KafkaProducerAdapter
kafka_producer = KafkaProducerAdapter(
bootstrap_servers=["localhost:9092"],
client_id="my-app",
)
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
message_broker=kafka.KafkaMessageBroker(
producer=kafka_producer,
aiokafka_log_level="ERROR",
),
)
```
### With Parallel Event Processing
Parallel events require `HANDLER` or `NONE` (the default). SEND infers sequential processing and rejects an explicit `True`.
```python
from cqrs import ScopeStrategy
# Enable parallel event processing (HANDLER / NONE only)
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
scope_strategy=ScopeStrategy.HANDLER, # or omit for NONE
max_concurrent_event_handlers=5, # Process up to 5 events concurrently
concurrent_event_handle_enable=True, # Enable parallel processing
)
```
### With Custom Middlewares
```python
from cqrs.middlewares import base
class CustomMiddleware(base.Middleware):
async def __call__(self, request: cqrs.Request, handle):
print(f"Before handling {type(request).__name__}")
result = await handle(request)
print(f"After handling {type(request).__name__}")
return result
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
middlewares=[CustomMiddleware()],
)
```
### With On Startup Callbacks
```python
def initialize_database():
# Initialize database connections, create tables, etc.
print("Database initialized")
def setup_cache():
# Setup cache connections
print("Cache initialized")
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
on_startup=[initialize_database, setup_cache],
)
```
### Complete Example
```python
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
from cqrs.message_brokers import devnull
from cqrs.middlewares import base
class CreateUserCommand(cqrs.Request):
user_id: str
email: str
class UserCreatedEvent(cqrs.DomainEvent):
user_id: str
email: str
class CreateUserCommandHandler(cqrs.RequestHandler[CreateUserCommand, None]):
def __init__(self):
self._events = []
@property
def events(self) -> list[cqrs.Event]:
return self._events
async def handle(self, request: CreateUserCommand) -> None:
# Business logic
self._events.append(
UserCreatedEvent(user_id=request.user_id, email=request.email)
)
class UserCreatedEventHandler(cqrs.EventHandler[UserCreatedEvent]):
async def handle(self, event: UserCreatedEvent) -> None:
print(f"User {event.user_id} created with email {event.email}")
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(CreateUserCommand, CreateUserCommandHandler)
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(UserCreatedEvent, UserCreatedEventHandler)
# Complete configuration
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
message_broker=devnull.DevnullMessageBroker(),
max_concurrent_event_handlers=3,
concurrent_event_handle_enable=True,
on_startup=[lambda: print("Application started")],
)
# Use the mediator
result = await mediator.send(CreateUserCommand(user_id="1", email="user@example.com"))
```
# Saga Mediator
- **Back to Bootstrap Overview**
Return to the Bootstrap overview page with all configuration options.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md)
- **Saga Pattern**
Flow, storage, recovery, and compensation.
[Read More](https://mkdocs.python-cqrs.dev/latest/saga/index.md)
## Overview
The `SagaMediator` runs orchestrated sagas: it resolves the saga by context type, creates a transaction, and streams step results. It is bootstrapped via `cqrs.bootstrap.saga.bootstrap()` with a saga map, DI container, optional saga storage, and optional event mapping for domain events emitted by steps.
### Basic Configuration
```python
import di
import cqrs
from cqrs.bootstrap import saga as bootstrap
from cqrs.saga.storage.memory import MemorySagaStorage
def saga_mapper(mapper: cqrs.SagaMap) -> None:
mapper.bind(OrderContext, OrderSaga)
storage = MemorySagaStorage()
mediator = bootstrap.bootstrap(
di_container=di.Container(),
sagas_mapper=saga_mapper,
saga_storage=storage,
)
```
`saga.bootstrap(..., scope_strategy=ScopeStrategy.SEND)` works out of the box: domain events run sequentially in the saga UoW. `concurrent_event_handle_enable` defaults to `None` (`False` under SEND, `True` otherwise). Do not pass `concurrent_event_handle_enable=True` with SEND — that raises `ValueError`.
### With Domain Events
Steps can emit domain events; the mediator uses an event emitter and event map (same as request bootstrap). Register handlers via `domain_events_mapper`:
```python
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(InventoryReservedEvent, InventoryReservedEventHandler)
mediator = bootstrap.bootstrap(
di_container=di.Container(),
sagas_mapper=saga_mapper,
domain_events_mapper=events_mapper,
saga_storage=storage,
)
```
### With Message Broker
By default the event emitter uses `DevnullMessageBroker`. To publish events to Kafka or RabbitMQ, pass `message_broker`:
```python
from cqrs.message_brokers import kafka
from cqrs.adapters.kafka import KafkaProducerAdapter
kafka_producer = KafkaProducerAdapter(
bootstrap_servers=["localhost:9092"],
client_id="my-app",
)
mediator = bootstrap.bootstrap(
di_container=di.Container(),
sagas_mapper=saga_mapper,
domain_events_mapper=events_mapper,
saga_storage=storage,
message_broker=kafka.KafkaMessageBroker(
producer=kafka_producer,
aiokafka_log_level="ERROR",
),
)
```
### With Middlewares and On Startup
```python
from cqrs.middlewares import base
class SagaLoggingMiddleware(base.Middleware):
async def __call__(self, request: cqrs.Request, handle):
print(f"Before saga step: {type(request).__name__}")
result = await handle(request)
print(f"After saga step: {type(request).__name__}")
return result
def init_storage():
# e.g. create tables for SqlAlchemySagaStorage
pass
mediator = bootstrap.bootstrap(
di_container=di.Container(),
sagas_mapper=saga_mapper,
saga_storage=storage,
middlewares=[SagaLoggingMiddleware()],
on_startup=[init_storage],
)
```
### Executing a Saga
Use `mediator.execute(context, saga_id=...)` to run the saga. It returns an async iterator; consume it fully with `async for`, or close it with `aclose()` / `async with aclosing(...)`. An abandoned SEND stream holds the UoW until garbage collection.
```python
import uuid
from contextlib import aclosing
context = OrderContext(order_id="123", items=["item_1"], total_amount=100.0)
saga_id = uuid.uuid4()
async with aclosing(mediator.execute(context, saga_id=saga_id)) as stream:
async for step_result in stream:
print(f"Step completed: {step_result.step_type.__name__}")
```
For recovery, use the same `saga_id` and call `recover_saga(..., container=..., storage=..., scope_strategy=...)` with the **same** strategy as bootstrap. A plain container is enough — do not wrap `ScopeAwareContainer` yourself (see [Saga Recovery](https://mkdocs.python-cqrs.dev/latest/saga/recovery/index.md)).
### Complete Example
```python
import dataclasses
import uuid
import di
import cqrs
from cqrs.bootstrap import saga as bootstrap
from cqrs.saga.saga import Saga
from cqrs.handlers.saga import SagaStepHandler, SagaStepResult
from cqrs.saga.storage.memory import MemorySagaStorage
from cqrs.saga.models import SagaContext
from cqrs.models.response import Response
@dataclasses.dataclass
class OrderContext(SagaContext):
order_id: str
items: list[str]
total_amount: float
inventory_reservation_id: str | None = None
payment_id: str | None = None
class ReserveInventoryStep(SagaStepHandler[OrderContext, Response]):
def __init__(self, inventory_service):
self._inventory_service = inventory_service
async def act(self, context: OrderContext) -> SagaStepResult:
reservation_id = await self._inventory_service.reserve_items(
context.order_id, context.items
)
context.inventory_reservation_id = reservation_id
return self._generate_step_result(Response())
async def compensate(self, context: OrderContext) -> None:
if context.inventory_reservation_id:
await self._inventory_service.release_items(
context.inventory_reservation_id
)
class OrderSaga(Saga[OrderContext]):
steps = [ReserveInventoryStep]
# Register services in container
di_container = di.Container()
# di_container.bind(...)
def saga_mapper(mapper: cqrs.SagaMap) -> None:
mapper.bind(OrderContext, OrderSaga)
storage = MemorySagaStorage()
mediator = bootstrap.bootstrap(
di_container=di_container,
sagas_mapper=saga_mapper,
saga_storage=storage,
scope_strategy=cqrs.ScopeStrategy.SEND, # sequential events; no concurrent=False needed
)
from contextlib import aclosing
context = OrderContext(order_id="123", items=["item_1"], total_amount=100.0)
saga_id = uuid.uuid4()
async with aclosing(mediator.execute(context, saga_id=saga_id)) as stream:
async for step_result in stream:
print(f"Step: {step_result.step_type.__name__}")
```
## Bootstrap Parameters
| Parameter | Description |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `di_container` | DI container (`di.Container` or CQRS `Container`) for resolving saga step handlers |
| `sagas_mapper` | Callable that receives `cqrs.SagaMap` and registers context type → saga class (e.g. `mapper.bind(OrderContext, OrderSaga)`) |
| `saga_storage` | Optional `ISagaStorage` implementation. If `None`, defaults to in-memory behaviour when storage is needed. For production, use e.g. `SqlAlchemySagaStorage` and register it in the container |
| `domain_events_mapper` | Optional callable to register event handlers (for events emitted by steps) |
| `message_broker` | Optional message broker for event publishing; defaults to `DevnullMessageBroker` |
| `middlewares` | Optional list of middlewares for request processing |
| `on_startup` | Optional list of callables invoked once when bootstrap runs |
| `scope_strategy` | `ScopeStrategy.NONE` (default), `SEND`, or `HANDLER`. SEND shares one UoW for the saga, fallback, and domain events. |
| `max_concurrent_event_handlers` | Max concurrent event handlers (default: 1) |
| `concurrent_event_handle_enable` | `None` by default: `False` under SEND (sequential BFS), `True` otherwise. Explicit `True` with SEND raises `ValueError`. |
# Streaming Mediator
- **Back to Bootstrap Overview**
Return to the Bootstrap overview page with all configuration options.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md)
- **Stream Handling**
Incremental processing, SSE, and streaming configuration.
[Read More](https://mkdocs.python-cqrs.dev/latest/stream_handling/index.md)
______________________________________________________________________
## Overview
The `StreamingRequestMediator` processes requests incrementally, yielding results as they become available. Perfect for batch processing, file uploads, and real-time progress updates.
### Basic Configuration
```python
from cqrs.bootstrap import requests as bootstrap
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(ProcessFilesCommand, ProcessFilesCommandHandler)
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(FileProcessedEvent, FileProcessedEventHandler)
mediator = bootstrap.bootstrap_streaming(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
)
```
`StreamingRequestMediator(..., scope_strategy=ScopeStrategy.SEND)` and `bootstrap_streaming(..., scope_strategy=ScopeStrategy.SEND)` work out of the box: `concurrent_event_handle_enable` defaults to `None` (`False` under SEND). Do not pass `concurrent_event_handle_enable=True` with SEND. Consume the stream fully or close it with `aclose()` / `async with aclosing(...)` — an abandoned SEND stream holds the UoW until GC.
```python
from cqrs import ScopeStrategy
mediator = bootstrap.bootstrap_streaming(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
scope_strategy=ScopeStrategy.SEND, # sequential events; no concurrent=False needed
)
```
### With Parallel Event Processing
Without SEND, `concurrent_event_handle_enable=None` becomes `True` (streaming default). Under SEND it becomes `False` (sequential BFS). Parallel events need `HANDLER` or `NONE`.
```python
from cqrs import ScopeStrategy
# Streaming mediator: None → True unless SEND
mediator = bootstrap.bootstrap_streaming(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
scope_strategy=ScopeStrategy.HANDLER, # or omit for NONE
max_concurrent_event_handlers=10, # Default: 10
concurrent_event_handle_enable=True,
)
```
### With Message Broker
```python
from cqrs.message_brokers import kafka
from cqrs.adapters.kafka import KafkaProducerAdapter
kafka_producer = KafkaProducerAdapter(
bootstrap_servers=["localhost:9092"],
)
mediator = bootstrap.bootstrap_streaming(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
message_broker=kafka.KafkaMessageBroker(producer=kafka_producer),
)
```
### Complete Example
```python
import typing
import asyncio
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
from cqrs.handlers.request import StreamingRequestHandler
from cqrs.message_brokers import devnull
class ProcessFilesCommand(cqrs.Request):
file_ids: list[str]
class FileProcessedResult(cqrs.Response):
file_id: str
status: str
class FileProcessedEvent(cqrs.DomainEvent):
file_id: str
status: str
class ProcessFilesCommandHandler(
StreamingRequestHandler[ProcessFilesCommand, FileProcessedResult]
):
def __init__(self):
self._events = []
@property
def events(self) -> list[cqrs.Event]:
return self._events.copy()
def clear_events(self) -> None:
self._events.clear()
async def handle(
self, request: ProcessFilesCommand
) -> typing.AsyncIterator[FileProcessedResult]:
for file_id in request.file_ids:
# Simulate processing
await asyncio.sleep(0.1)
result = FileProcessedResult(file_id=file_id, status="completed")
self._events.append(
FileProcessedEvent(file_id=file_id, status="completed")
)
yield result
class FileProcessedEventHandler(cqrs.EventHandler[FileProcessedEvent]):
async def handle(self, event: FileProcessedEvent) -> None:
print(f"File {event.file_id} processed")
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(ProcessFilesCommand, ProcessFilesCommandHandler)
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(FileProcessedEvent, FileProcessedEventHandler)
mediator = bootstrap.bootstrap_streaming(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
message_broker=devnull.DevnullMessageBroker(),
max_concurrent_event_handlers=5,
concurrent_event_handle_enable=True,
)
# Stream results — exhaust or aclose(); abandoned SEND holds UoW until GC
from contextlib import aclosing
async with aclosing(
mediator.stream(ProcessFilesCommand(file_ids=["1", "2", "3"]))
) as stream:
async for result in stream:
if result:
print(f"Processed: {result.file_id} - {result.status}")
```
# Dependency injection
# Dependency Injection
Dependency Injection (DI) is a design pattern that allows injecting dependencies into application components, simplifying their management and improving code testability.
## Overview
The `python-cqrs` package supports multiple DI container libraries:
- **`di`** — Lightweight, modern dependency injection library (default)
- **`dependency-injector`** — Feature-rich DI library with configuration management and FastAPI integration
- **dishka** — Optional (`pip install python-cqrs[dishka]`) with first-class request scopes
Both `di` and dishka allow generator providers whose cleanup runs when the CQRS scope exits. See [Scoped Dependencies](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/index.md).
Prerequisites
This section assumes you've already configured [Bootstrap](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md). The DI container is passed to bootstrap functions to resolve handlers and their dependencies.
Next Steps
After understanding DI, proceed to [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) to learn how handlers use dependency injection. For request-scoped UoW / sessions, read [Scoped Dependencies](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/index.md).
## Supported Libraries
| Feature | `di` | dishka | `dependency-injector` |
| ----------------------------- | -------------------- | ---------------------- | --------------------------------------- |
| **Default** | ✅ Yes (default) | ❌ Optional extra | ❌ No |
| **Type-based resolution** | ✅ Yes | ✅ Yes | ✅ Yes |
| **Per-request scope (CQRS)** | ✅ Yes | ✅ Yes | ❌ No (`SupportsScope` not implemented) |
| **Async generator providers** | ✅ Yes | ✅ Yes | ❌ No |
| **Configuration management** | ❌ No | ❌ No | ✅ Yes (YAML, dict, Pydantic) |
| **Resource management** | ❌ No | ✅ Yes | ✅ Yes |
| **FastAPI integration** | ✅ Yes | ✅ Yes | ✅ Yes (direct wiring) |
| **Nested containers** | ❌ No | ✅ Yes | ✅ Yes |
| **Learning curve** | 🟢 Easy | 🟢 Easy | 🟡 Moderate |
| **Best for** | Small to medium apps | Apps already on dishka | Large, complex apps |
### `di` Library
The `di` library is the default DI container for `python-cqrs`. It provides:
| Feature | Description |
| ------------------------- | ---------------------------------------------------- |
| **Type-based resolution** | Automatic dependency resolution by type |
| **Scoped dependencies** | Support for singleton, request, and scoped lifetimes |
| **Simple API** | Easy to use and configure |
| **Type safety** | Full type checking support |
When to use `di`
Use `di` for most applications. It's lightweight, simple, and provides everything you need for dependency injection.
### `dependency-injector` Library
The `dependency-injector` library offers advanced features:
| Feature | Description |
| ---------------------------- | ------------------------------------------------------ |
| **Configuration management** | Built-in support for YAML, dict, and Pydantic settings |
| **Resource management** | Automatic initialization and cleanup of resources |
| **Container wiring** | Direct injection into FastAPI endpoints |
| **Nested containers** | Better organization for large applications |
When to use `dependency-injector`
Use `dependency-injector` when you need advanced features like configuration management, resource lifecycle, or nested containers for large applications.
## Key Concepts
### Binding Implementations
Dependencies are bound by type, allowing the container to automatically resolve implementations:
```python
container.bind(
di.bind_by_type(
Dependent(ImplementationClass, scope="request"),
InterfaceProtocol
)
)
```
### Dependency Scopes
The `di` library supports different scopes:
| Scope | Lifetime | Use Case |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ |
| **`"singleton"`** | One instance per container (shared across all requests) | Stateless services, configuration |
| **`"request"`** | Provider lifetime inside `di` / dishka. Becomes one instance per CQRS `send()` / handler **only** if you also pass `scope_strategy=` | Stateful services, database sessions / UoW |
| **`"scoped"`** | One instance per scope (custom scope management) | Request-scoped resources |
CQRS request scope
`di`'s `scope="request"` is **not** a CQRS scope by itself. Without `scope_strategy=`, a generator provider still finishes before `handle`. Pass `scope_strategy=ScopeStrategy.SEND` so `"request"` dependencies live for the whole `mediator.send()` (including domain events). Details: [Scoped Dependencies](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/index.md).
**Scope Examples**
```python
# Singleton - shared across all requests
container.bind(
di.bind_by_type(
Dependent(ConfigService, scope="singleton"),
ConfigServiceProtocol
)
)
# Request - new instance per request
container.bind(
di.bind_by_type(
Dependent(DatabaseConnection, scope="request"),
DatabaseProtocol
)
)
```
### Automatic Resolution
Handlers receive dependencies automatically through constructor injection:
```python
class MyHandler(RequestHandler[MyCommand, None]):
def __init__(self, service: ServiceProtocol) -> None:
self._service = service
```
The container automatically resolves `ServiceProtocol` and injects it into the handler.
## Integration with Bootstrap
DI containers are integrated with the bootstrap process:
```python
mediator = bootstrap.bootstrap(
di_container=container,
commands_mapper=commands_mapper,
queries_mapper=queries_mapper,
domain_events_mapper=domain_events_mapper,
)
```
The container is used to resolve all handlers and their dependencies automatically.
## Benefits
Using dependency injection with `python-cqrs` provides:
| Benefit | Description |
| ---------------------------- | ----------------------------------------------- |
| **Simplified management** | Container handles creation and lifecycle |
| **Improved testability** | Easy to mock dependencies for unit testing |
| **Flexibility** | Swap implementations without changing core code |
| **Separation of concerns** | Dependencies explicitly declared |
| **Configuration management** | Centralized dependency configuration |
## Best Practices
| Practice | Description | Example |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| **Use interfaces** | Always bind implementations to interfaces, not concrete classes | `ServiceProtocol` → `ServiceImplementation` |
| **Choose scopes wisely** | Use singleton for stateless services, request for stateful ones | Config: singleton, DB: request |
| **Keep constructors simple** | Avoid complex logic in constructors | Move logic to methods |
| **UoW / sessions: generator + SEND** | Do not inject a UoW factory as best practice. Bind an async generator and pass `scope_strategy=ScopeStrategy.SEND` so cleanup runs after `handle` | See [Scoped Dependencies](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/index.md) |
| **Factories for construction only** | Use a factory when the object does not need a request-scoped lifetime | `create_database_connection()` for a pool, not for the per-send session |
| **Test with mocks** | Always test handlers with mocked dependencies | Mock `ServiceProtocol` in tests |
Common Mistakes
- ❌ Binding concrete classes instead of interfaces
- ❌ Using singleton for stateful services
- ❌ Injecting a UoW/session **factory** instead of a generator + `scope_strategy=SEND`
- ❌ Complex logic in constructors
- ❌ Not testing with mocks
Testing Tips
Always test handlers with mocked dependencies. This ensures your handlers are testable and don't depend on external services.
# Scoped Dependencies
By default, the container opens and closes a scope **inside** `resolve()`. A generator such as `async def session() -> AsyncIterator[AsyncSession]` therefore finishes **before** `handle`, which is why people inject factories.
Pass `scope_strategy=ScopeStrategy.SEND` and that session lives until `mediator.send()` returns — including domain-event handlers that run afterwards.
If you do not use generator providers, leave the default `ScopeStrategy.NONE` and change nothing.
## Example
Copy this as-is. Cleanup (`close`) runs after `handle`. Runnable file: [`examples/di/scoped_dependencies_di.py`](https://github.com/vadikko2/python-cqrs/blob/master/examples/di/scoped_dependencies_di.py).
```python
from __future__ import annotations
import asyncio
import typing
import di
from di import dependent
import cqrs
from cqrs.bootstrap import requests as bootstrap
class IUoW(typing.Protocol):
async def commit(self) -> None: ...
class UoW:
async def commit(self) -> None:
print("commit")
async def close(self) -> None:
print("close")
async def uow_provider() -> typing.AsyncIterator[UoW]:
uow = UoW()
try:
yield uow
finally:
await uow.close() # after handle, not before
class CancelTask(cqrs.Request):
task_id: int
class CancelTaskHandler(cqrs.RequestHandler[CancelTask, None]):
def __init__(self, uow: IUoW) -> None:
self._uow = uow
@property
def events(self) -> typing.List[cqrs.Event]:
return []
async def handle(self, request: CancelTask) -> None:
await self._uow.commit()
def setup_di() -> di.Container:
container = di.Container()
container.bind(
di.bind_by_type(
dependent.Dependent(uow_provider, scope="request"),
IUoW,
),
)
return container
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(CancelTask, CancelTaskHandler)
async def main() -> None:
mediator = bootstrap.bootstrap(
di_container=setup_di(),
commands_mapper=commands_mapper,
scope_strategy=cqrs.ScopeStrategy.SEND,
)
await mediator.send(CancelTask(task_id=42))
if __name__ == "__main__":
asyncio.run(main())
```
di scope=request is not a CQRS scope
`di`'s `scope="request"` only describes **provider lifetime** inside the `di` library. python-cqrs opens a CQRS scope **only** when you pass `scope_strategy=`. Without it, the generator still finishes before `handle`.
## Which strategy?
- **SEND** — one UoW per `send()` (command, fallback, and domain events).
- **HANDLER** — a fresh UoW per handler, including fallback (the primary rolls back first).
- **NONE** — legacy one-shot resolve; the default.
## Next
- **Tutorial**
Command + domain event + outbox on one `AsyncSession` under SEND. The page to copy.
[Read More](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/tutorial/index.md)
- **Strategies**
SEND vs HANDLER vs NONE, a decision table, and fallback in one screen.
[Read More](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/strategies/index.md)
- **Containers**
Full `di`, dishka, and dependency-injector snippets (not fragments).
[Read More](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/containers/index.md)
- **Advanced**
`enter_scope`, `bind_scope`, several `send()` calls, FastAPI middleware.
[Read More](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/advanced/index.md)
- **Custom Container**
When you need `SupportsScope.open_scope` for a DI library we do not ship.
[Read More](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/custom_container/index.md)
- **Troubleshooting**
Generator finished too early, dirty sessions, streams, sagas.
[Read More](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/troubleshooting/index.md)
## Before / after
Without a CQRS scope you inject a factory and open the UoW yourself:
```python
class CancelTaskHandler(cqrs.RequestHandler[CancelTask, None]):
def __init__(self, uow_factory: Callable[[], AbstractAsyncContextManager[IUoW]]) -> None:
self._uow_factory = uow_factory
async def handle(self, command: CancelTask) -> None:
async with self._uow_factory() as uow:
await uow.tasks.cancel(command.task_id)
await uow.commit()
```
With `scope_strategy=ScopeStrategy.SEND`, inject the live object:
```python
class CancelTaskHandler(cqrs.RequestHandler[CancelTask, None]):
def __init__(self, uow: IUoW) -> None:
self._uow = uow
async def handle(self, command: CancelTask) -> None:
await self._uow.tasks.cancel(command.task_id)
await self._uow.commit()
```
| Benefit | Detail |
| ------------------- | ------------------------------------------------------------- |
| Less boilerplate | No factory / context-manager parameters in handlers |
| Shared unit of work | SEND: fallback and domain events reuse the command’s UoW |
| Isolated fallback | HANDLER: primary rolls back, then fallback gets a fresh scope |
| Guaranteed cleanup | Exceptions still exit the scope (rollback / close) |
| Easier tests | Mock `IUoW` directly instead of a factory of context managers |
# Advanced
- **Back to Scoped Dependencies Overview**
Return to the Scoped Dependencies overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/index.md)
- **Custom Container**
Implement `SupportsScope` when you are not using `di` or dishka.
[Read More](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/custom_container/index.md)
______________________________________________________________________
Stay on the [Tutorial](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/tutorial/index.md) unless you need one of these:
- several `mediator.send()` calls in **one** UoW
- a scope already opened by FastAPI / dishka middleware (`bind_scope`)
- streams, sagas, or `recover_saga` with the same strategy as bootstrap
```
flowchart TD
Send["mediator.send(command)"] --> Strategy{scope_strategy}
Strategy -->|SEND| OpenSend["enter_scope"]
Strategy -->|HANDLER| Dispatch
Strategy -->|NONE| Dispatch
OpenSend --> Dispatch["RequestDispatcher.dispatch"]
Dispatch --> Resolve["resolve from active scope or root"]
Resolve --> Handle["handler.handle"]
Handle --> Events["EventProcessor / EventEmitter"]
Events --> CloseSend["scope exit → cleanup"]
External["FastAPI / dishka middleware"] -.-> Resolve
```
## Several `send()` calls, one UoW
`enter_scope` opens a scope and inner **SEND** `send()` calls **join** it (`reuse_existing=True` by default). HANDLER never joins; it always nests.
```python
import cqrs
async with cqrs.enter_scope(container):
await mediator.send(ReserveStock(...))
await mediator.send(ChargePayment(...))
```
Use this for a multi-command unit of work. One command still only needs `scope_strategy=ScopeStrategy.SEND` on bootstrap — no manual `enter_scope`.
## FastAPI / dishka middleware (`bind_scope`)
When middleware already opened a scoped container, attach it so **SEND** (or **NONE**) resolves reuse that instance:
```python
from fastapi import Depends, FastAPI, Request
import cqrs
from cqrs.container.dishka import DishkaCQRSContainer
from cqrs.container.scope import bind_scope
app = FastAPI()
@app.post("/tasks/{task_id}/cancel")
async def cancel(
task_id: int,
request: Request,
mediator: cqrs.RequestMediator = Depends(...),
):
async with bind_scope(DishkaCQRSContainer.of(request.state.dishka_container)):
await mediator.send(CancelTask(task_id=task_id))
```
CLI / no-FastAPI equivalent: [`examples/di/scoped_dependencies_fastapi.py`](https://github.com/vadikko2/python-cqrs/blob/master/examples/di/scoped_dependencies_fastapi.py).
`bind_scope` + HANDLER
HANDLER always opens a **nested** scope (`reuse_existing=False`). Command and domain-event handlers will **not** share the middleware UoW. Use SEND when you want that sharing.
## Streams
Under **SEND**, the mediator opens one scope around the whole stream (inside the async generator). Consume it fully or close it:
```python
from contextlib import aclosing
async with aclosing(mediator.stream(command)) as stream:
async for result in stream:
...
```
An abandoned SEND stream holds the UoW until garbage collection. Prefer short-lived streams. Details: [Stream configuration](https://mkdocs.python-cqrs.dev/latest/stream_handling/configuration/index.md).
## Sagas and recovery
- **SEND** — one UoW for the whole saga, including compensation and fallback. Preferred when steps share a session.
- **HANDLER** — a new scope per step. Compensation **re-resolves** the step, so do not keep compensation state on `self`; put it in `SagaContext`.
Pass the **same** `scope_strategy` to `saga.transaction(...)` and `recover_saga(...)` as the original run. A plain `di.Container` is enough — do not wrap it yourself.
Do not call `recover_saga` from a `send()` that already opened SEND: recovery would join that request UoW.
See [Saga Recovery](https://mkdocs.python-cqrs.dev/latest/saga/recovery/index.md) and [Compensation](https://mkdocs.python-cqrs.dev/latest/saga/compensation/index.md).
# Containers
- **Back to Scoped Dependencies Overview**
Return to the Scoped Dependencies overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/index.md)
______________________________________________________________________
| Feature | `di` | dishka | dependency-injector |
| ------------------------- | ----------------------------------- | --------------------------------- | --------------------------------- |
| Adapter | raw `di.Container` or `DIContainer` | `DishkaCQRSContainer` | `DependencyInjectorCQRSContainer` |
| Per-request CQRS scope | ✅ | ✅ (`Scope.REQUEST`) | ❌ (no-op `SupportsScope`) |
| Async generator providers | ✅ | ✅ | ❌ |
| Extra install | default | `pip install python-cqrs[dishka]` | default |
di scope=request ≠ CQRS scope
Binding with `scope="request"` (or dishka `Scope.REQUEST`) only configures the **DI library**. The generator stays open across `handle` only when bootstrap gets `scope_strategy=ScopeStrategy.SEND` (or `HANDLER`).
## `di`
```python
from __future__ import annotations
import asyncio
import typing
import di
from di import dependent
import cqrs
from cqrs.bootstrap import requests as bootstrap
class IUoW(typing.Protocol):
async def commit(self) -> None: ...
async def close(self) -> None: ...
class UoW:
async def commit(self) -> None:
pass
async def close(self) -> None:
pass
async def uow_provider() -> typing.AsyncIterator[UoW]:
uow = UoW()
try:
yield uow
finally:
await uow.close()
class CancelTask(cqrs.Request):
task_id: int
class CancelTaskHandler(cqrs.RequestHandler[CancelTask, None]):
def __init__(self, uow: IUoW) -> None:
self._uow = uow
@property
def events(self) -> typing.List[cqrs.Event]:
return []
async def handle(self, request: CancelTask) -> None:
await self._uow.commit()
def setup_di() -> di.Container:
container = di.Container()
container.bind(
di.bind_by_type(
dependent.Dependent(uow_provider, scope="request"),
IUoW,
),
)
return container
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(CancelTask, CancelTaskHandler)
async def main() -> None:
mediator = bootstrap.bootstrap(
di_container=setup_di(),
commands_mapper=commands_mapper,
scope_strategy=cqrs.ScopeStrategy.SEND,
)
await mediator.send(CancelTask(task_id=1))
if __name__ == "__main__":
asyncio.run(main())
```
See [`examples/di/scoped_dependencies_di.py`](https://github.com/vadikko2/python-cqrs/blob/master/examples/di/scoped_dependencies_di.py). SQLAlchemy + outbox: [Tutorial](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/tutorial/index.md).
## dishka
Register **handlers** as dishka providers (`Scope.REQUEST`). Install with `pip install python-cqrs[dishka]`.
```python
from __future__ import annotations
import asyncio
import typing
from dishka import Provider, Scope, make_async_container, provide
import cqrs
from cqrs.container.dishka import DishkaCQRSContainer
from cqrs.bootstrap import requests as bootstrap
class UoW:
def __init__(self) -> None:
self.id = id(self)
async def commit(self) -> None:
pass
class CancelTask(cqrs.Request):
task_id: int
class CancelTaskHandler(cqrs.RequestHandler[CancelTask, None]):
def __init__(self, uow: UoW) -> None:
self._uow = uow
@property
def events(self) -> typing.List[cqrs.Event]:
return []
async def handle(self, request: CancelTask) -> None:
await self._uow.commit()
class AppProvider(Provider):
@provide(scope=Scope.REQUEST)
async def uow(self) -> typing.AsyncIterator[UoW]:
uow = UoW()
try:
yield uow
finally:
pass
cancel_handler = provide(CancelTaskHandler, scope=Scope.REQUEST)
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(CancelTask, CancelTaskHandler)
async def main() -> None:
dishka = make_async_container(AppProvider())
mediator = bootstrap.bootstrap(
di_container=DishkaCQRSContainer(dishka),
commands_mapper=commands_mapper,
scope_strategy=cqrs.ScopeStrategy.SEND,
)
await mediator.send(CancelTask(task_id=7))
await dishka.close()
if __name__ == "__main__":
asyncio.run(main())
```
See [`examples/di/scoped_dependencies_dishka.py`](https://github.com/vadikko2/python-cqrs/blob/master/examples/di/scoped_dependencies_dishka.py).
## dependency-injector
The library has no per-request scope API, so the adapter does **not** implement `SupportsScope`. Generator providers will not stay alive until `handle` finishes.
Use `providers.Singleton` / `Resource` for process-level resources (pools) and `providers.Factory` for a new UoW per resolve **without** generator finalization. For a scoped session / outbox, prefer `di` or dishka.
```python
from __future__ import annotations
import asyncio
import typing
from dependency_injector import containers, providers
import cqrs
from cqrs.container.dependency_injector import DependencyInjectorCQRSContainer
from cqrs.bootstrap import requests as bootstrap
class ConnectionPool:
pass
class UoW:
def __init__(self, pool: ConnectionPool) -> None:
self.pool = pool
async def commit(self) -> None:
pass
class CancelTask(cqrs.Request):
task_id: int
class CancelTaskHandler(cqrs.RequestHandler[CancelTask, None]):
def __init__(self, uow: UoW) -> None:
self._uow = uow
@property
def events(self) -> typing.List[cqrs.Event]:
return []
async def handle(self, request: CancelTask) -> None:
await self._uow.commit()
class ApplicationContainer(containers.DeclarativeContainer):
pool = providers.Singleton(ConnectionPool)
uow = providers.Factory(UoW, pool=pool)
cancel_task_handler = providers.Factory(CancelTaskHandler, uow=uow)
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(CancelTask, CancelTaskHandler)
async def main() -> None:
app = ApplicationContainer()
cqrs_container = DependencyInjectorCQRSContainer()
cqrs_container.attach_external_container(app)
mediator = bootstrap.bootstrap(
di_container=cqrs_container,
commands_mapper=commands_mapper,
scope_strategy=cqrs.ScopeStrategy.SEND, # no-op for this adapter
)
await mediator.send(CancelTask(task_id=1))
if __name__ == "__main__":
asyncio.run(main())
```
See [`examples/di/scoped_dependencies_dependency_injector.py`](https://github.com/vadikko2/python-cqrs/blob/master/examples/di/scoped_dependencies_dependency_injector.py).
# Custom Container
- **Back to Scoped Dependencies Overview**
Return to the Scoped Dependencies overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/index.md)
- **Advanced**
`enter_scope`, `bind_scope`, and FastAPI middleware.
[Read More](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/advanced/index.md)
______________________________________________________________________
You need this page only if you are **not** using `di` or dishka (or you must wrap a library python-cqrs does not ship). For a SQLAlchemy session that lasts for `send()`, copy the [Tutorial](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/tutorial/index.md) instead.
`Container.resolve` is enough for unscoped DI. To enable CQRS scopes, add `open_scope`:
```python
@typing.runtime_checkable
class SupportsScope(typing.Protocol[C]):
def open_scope(
self,
context: typing.Mapping[type, typing.Any] | None = None,
) -> typing.AsyncContextManager[Container[C]]: ...
```
## Checklist
1. **`open_scope` yields a new object**, never `self` — otherwise concurrent `send()` share one UoW.
1. **Finalize on exit** — commit/rollback/close generator providers in the context manager’s `__aexit__`.
1. **Propagate exceptions** — do not swallow handler errors so rollback can run. Under `HANDLER` fallback the framework re-raises through this exit so a generator UoW (`yield session; commit()`) rolls back the failed primary.
1. **Optional** — skipping `open_scope` is valid; the framework no-ops and behaviour stays as today.
1. **Structural typing** — `SupportsScope` is `runtime_checkable`; inheritance is not required.
1. **Do not wrap the container yourself** — mediators, `saga.transaction`, and `recover_saga` wrap a plain container internally. Resolving the **root** container directly inside `enter_scope` still one-shots; that is the intended contract.
See [`examples/di/scoped_dependencies_custom_container.py`](https://github.com/vadikko2/python-cqrs/blob/master/examples/di/scoped_dependencies_custom_container.py) for a ~80-line template.
# Scope Strategies
- **Back to Scoped Dependencies Overview**
Return to the Scoped Dependencies overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/index.md)
______________________________________________________________________
```python
from cqrs import ScopeStrategy
mediator = bootstrap.bootstrap(
di_container=container,
commands_mapper=commands_mapper,
scope_strategy=ScopeStrategy.SEND, # opt-in; default is NONE
)
```
| Strategy | Boundary | Typical use |
| ------------------ | -------------------------------------------------------------------------------------------- | --------------------------------------------- |
| **SEND** | One scope per `mediator.send()` / `stream()`, including domain events **and fallback** | Outbox / one transaction for command + events |
| **HANDLER** | Fresh scope per resolve+handle (command, event, saga step, **and fallback**). Always nested. | Isolated UoW; parallel events |
| **NONE** (default) | No framework scopes | No generators; unchanged legacy resolve |
## Decision
| I need… | Use |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Command, domain events, and outbox on one session | **SEND** ([Tutorial](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/tutorial/index.md)) |
| Fallback to start clean after the primary rolls back | **HANDLER** |
| Parallel event handlers | **HANDLER** or **NONE** — not SEND |
| No generator providers | **NONE** (omit `scope_strategy=`) |
## Fallback
There is no `fallback_shares_scope` flag — the strategy is the answer.
- **SEND** — fallback is another handler of the same `send()`. Same UoW, including a session that may need `rollback()` / a savepoint after a database error. Outbox rows from the primary stay in the session.
- **HANDLER** — the primary scope exits with **rollback** first (the generator sees `except`, not a successful commit). Then fallback opens its own scope. Primary writes are not visible.
- **NONE** — one-shot resolve, same as before scoped dependencies.
On a stream, items already yielded to the client are not cancelled; only the unit of work changes.
## Where the edge cases live
- Streams (`aclose` / `aclosing`), several `send()` calls, FastAPI `bind_scope`: [Advanced](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/advanced/index.md)
- Sagas, compensation, `recover_saga`: [Saga Recovery](https://mkdocs.python-cqrs.dev/latest/saga/recovery/index.md) and [Compensation](https://mkdocs.python-cqrs.dev/latest/saga/compensation/index.md)
- `ValueError` when SEND is combined with `concurrent_event_handle_enable=True`: [Troubleshooting](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/troubleshooting/index.md)
# Troubleshooting
- **Back to Scoped Dependencies Overview**
Return to the Scoped Dependencies overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/index.md)
______________________________________________________________________
| Symptom | Likely cause | Fix |
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Concurrent requests share one UoW | `open_scope` yields `self` | Always return a **new** scoped container instance |
| Cleanup never runs after early stream break | Generator not closed | Exhaust the stream or `aclose()` / `async with aclosing(...)`. SEND scope lives inside the generator body |
| Abandoned SEND stream holds a DB session | Iterator left unclosed; finally runs only on GC | Always exhaust or `aclose()` / `aclosing`. The ContextVar does **not** leak into the next `send()` |
| Same UoW for the whole process | Singleton / process-level provider | Use request-scoped providers (`di` scope `"request"`, dishka `Scope.REQUEST`) |
| Use-after-close / detached session | Accessing dependency after scope exit | Do not store scoped UoW on long-lived objects outside `handle` |
| Scopes seem to do nothing; generator provider finalizes before `handle` | `scope_strategy` not passed, so the default `NONE` is in effect | Pass `scope_strategy=ScopeStrategy.SEND` (or `HANDLER`) to bootstrap / the mediator |
| Bound `scope="request"` / dishka `Scope.REQUEST` but the session still dies before `handle` | That flag is the **DI library** lifetime. It is not a CQRS scope until `scope_strategy=` is set | Pass `scope_strategy=ScopeStrategy.SEND` (or `HANDLER`). `di` `scope=request` ≠ CQRS scope by itself |
| Event handler got a different UoW unexpectedly | `ScopeStrategy.HANDLER`, or `bind_scope` under HANDLER | Use `SEND` for a shared UoW; `bind_scope` only shares under SEND/NONE — HANDLER always nests |
| Command and its events use different UoWs although both are SEND | Hand-built `EventEmitter` created with a different `scope_strategy` than the mediator | Pass the same strategy to both (bootstrap does this for you); the mediator logs a warning on mismatch |
| `ValueError`: SEND cannot be used with `concurrent_event_handle_enable=True` | Explicit `True` with SEND (any `max`) | Omit `concurrent_event_handle_enable` under SEND (`None` → `False`). For parallel events use `HANDLER` / `NONE` |
| Fallback sees a dirty / `needs rollback` session | SEND shares the primary UoW; a DB error left the session unusable | In fallback call `rollback()` or use a savepoint, or switch to `HANDLER` for a fresh session |
| Fallback does not see primary writes; primary committed a failed unit of work | Expected under HANDLER: primary rolls back, then fallback opens a new scope | Use `SEND` if fallback must share outbox / partial writes. Persist saga state in `SagaContext` |
| Saga `compensate` sees empty/stale state set in `act` | Under `HANDLER` compensation re-resolves the step in a fresh scope, so it is a new instance | Store what compensation needs in `SagaContext`, or use `SEND` to keep one instance and UoW per saga |
| Recovery uses a different UoW / one-shot resolve | `recover_saga` called without the original `scope_strategy`, or without `container` / `storage` | Pass the **same** `scope_strategy` as the original run, plus `container` and `storage`. A plain container is enough — do not wrap `ScopeAwareContainer` yourself |
| Recovery joined an HTTP request’s UoW | `recover_saga(SEND)` called from inside `send()` / an already open SEND scope (`reuse_existing=True`) | Run recovery from a worker, not from a request that already opened SEND |
| Long-held DB session / pool exhaustion | SEND scope over a long or abandoned stream | Keep streams short; exhaust or `aclose()` the consumer promptly |
| dishka `NoFactoryError` for handlers | Handlers not registered | `provide(MyHandler, scope=Scope.REQUEST)` in a dishka `Provider` |
| `ValueError` resetting ContextVar | Async-generator context leak | Framework catches this; prefer exhausting/aclosing streams so reset runs in the same context |
| dependency-injector generators never finalize | No `SupportsScope` | Expected; switch to `di` / dishka for scoped generators |
# Tutorial: one session for command, event, and outbox
- **Back to Scoped Dependencies Overview**
Return to the Scoped Dependencies overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/index.md)
______________________________________________________________________
This is the page to copy. One `mediator.send()` under `ScopeStrategy.SEND` keeps a **single** SQLAlchemy `AsyncSession` for:
1. the command handler (business row + outbox row)
1. the domain-event handler (audit row)
1. `SqlAlchemyOutboxedEventRepository` built from **that same session**
The generator commits **after** both handlers finish, so all three writes are one transaction. SQLite is enough; you do not need Postgres to learn this.
Do not call `session_factory()` in the outbox bind
`lambda: SqlAlchemyOutboxedEventRepository(session=session_factory())` opens a **new** session on every `resolve()`. The command and the event would not share a transaction, and the scoped generator would not own that session. Bind `AsyncSession` as a generator, then `outbox_from_session(session)`.
di scope=request is not enough
That flag is provider lifetime inside `di`. python-cqrs only keeps the generator open when you pass `scope_strategy=ScopeStrategy.SEND` (or wrap several `send()` calls in `enter_scope` — [Advanced](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/advanced/index.md)).
Runnable file: [`examples/di/scoped_dependencies_sqlalchemy.py`](https://github.com/vadikko2/python-cqrs/blob/master/examples/di/scoped_dependencies_sqlalchemy.py) (`pip install -e ".[examples]"` for `aiosqlite`).
```python
from __future__ import annotations
import asyncio
import typing
import di
import pydantic
from di import dependent
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.pool import StaticPool
import cqrs
from cqrs.bootstrap import requests as bootstrap
class Base(DeclarativeBase):
pass
class TaskRow(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
cancelled: Mapped[bool] = mapped_column(default=False)
class AuditRow(Base):
__tablename__ = "audit"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
task_id: Mapped[int]
note: Mapped[str]
def _create_sqlite_outbox_table(sync_connection: typing.Any) -> None:
# OutboxModel.id uses Identity(); SQLite needs AUTOINCREMENT. Same helper as fastapi_outbox.
sync_connection.exec_driver_sql(
"""
CREATE TABLE IF NOT EXISTS outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id BLOB NOT NULL,
event_id_bin BLOB NOT NULL,
event_status VARCHAR(12) NOT NULL,
flush_counter SMALLINT NOT NULL DEFAULT 0,
event_name VARCHAR(255) NOT NULL,
topic VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
payload BLOB NOT NULL,
CONSTRAINT event_id_unique_index UNIQUE (event_id_bin, event_name)
)
""",
)
engine = create_async_engine(
"sqlite+aiosqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def init_schema() -> None:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.run_sync(_create_sqlite_outbox_table)
async def session_provider() -> typing.AsyncIterator[AsyncSession]:
session = SessionLocal()
try:
yield session
await session.commit() # after command + domain events
except Exception:
await session.rollback()
raise
finally:
await session.close()
def outbox_from_session(
session: AsyncSession,
) -> cqrs.SqlAlchemyOutboxedEventRepository:
return cqrs.SqlAlchemyOutboxedEventRepository(session)
class TaskCancelledPayload(pydantic.BaseModel, frozen=True):
task_id: int
cqrs.OutboxedEventMap.register(
"scoped_task_cancelled",
cqrs.NotificationEvent[TaskCancelledPayload],
)
class CancelTask(cqrs.Request):
task_id: int
class TaskCancelled(cqrs.DomainEvent, frozen=True):
task_id: int
class CancelTaskHandler(cqrs.RequestHandler[CancelTask, None]):
def __init__(
self,
session: AsyncSession,
outbox: cqrs.OutboxedEventRepository,
) -> None:
self._session = session
self._outbox = outbox
self._events: list[cqrs.Event] = []
@property
def events(self) -> typing.List[cqrs.Event]:
return self._events
async def handle(self, request: CancelTask) -> None:
self._session.add(TaskRow(id=request.task_id, cancelled=True))
self._outbox.add(
cqrs.NotificationEvent[TaskCancelledPayload](
event_name="scoped_task_cancelled",
topic="tasks",
payload=TaskCancelledPayload(task_id=request.task_id),
),
)
# Do not call outbox.commit() here — that would commit before the event handler.
self._events.append(TaskCancelled(task_id=request.task_id))
class TaskCancelledHandler(cqrs.EventHandler[TaskCancelled]):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def handle(self, event: TaskCancelled) -> None:
self._session.add(AuditRow(task_id=event.task_id, note="cancelled"))
def setup_di() -> di.Container:
container = di.Container()
container.bind(
di.bind_by_type(
dependent.Dependent(session_provider, scope="request"),
AsyncSession,
),
)
container.bind(
di.bind_by_type(
dependent.Dependent(outbox_from_session, scope="request"),
cqrs.OutboxedEventRepository,
),
)
return container
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(CancelTask, CancelTaskHandler)
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(TaskCancelled, TaskCancelledHandler)
async def main() -> None:
await init_schema()
mediator = bootstrap.bootstrap(
di_container=setup_di(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
scope_strategy=cqrs.ScopeStrategy.SEND,
)
await mediator.send(CancelTask(task_id=7))
async with SessionLocal() as session:
tasks = (await session.execute(select(TaskRow))).scalars().all()
audits = (await session.execute(select(AuditRow))).scalars().all()
pending = await cqrs.SqlAlchemyOutboxedEventRepository(session).get_many(
topic="tasks",
)
assert len(tasks) == 1 and tasks[0].cancelled is True
assert len(audits) == 1 and audits[0].task_id == 7
assert len(pending) == 1
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())
```
## What to notice
- **Same session.** `outbox_from_session(session)` receives the generator’s `AsyncSession`. The event handler asks for `AsyncSession` too. Under SEND, both resolves hit the same scoped instance.
- **Commit in the generator.** `yield` then `commit()`, `except` then `rollback()`. If the command handler called `outbox.commit()` (`session.commit()`), the domain-event writes would start a new transaction on the same session object.
- **SQLite outbox DDL.** `OutboxModel.id` uses `Identity()`, which SQLite does not autoincrement. The `CREATE TABLE` above matches [`examples/outbox/fastapi_outbox.py`](https://github.com/vadikko2/python-cqrs/blob/master/examples/outbox/fastapi_outbox.py). MySQL/Postgres can use `OutboxModel.metadata.create_all`.
Next: [which strategy to pick](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/strategies/index.md), or [di vs dishka vs dependency-injector](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/containers/index.md).
# Why Scoped Dependencies
- **Back to Scoped Dependencies Overview**
Start with the problem, a full example, and when to use SEND.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/index.md)
- **Tutorial**
Command + domain event + outbox on one `AsyncSession`.
[Read More](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/tutorial/index.md)
______________________________________________________________________
The onboarding story now lives on [Overview](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/index.md) (three sentences, a copy-paste example, then when to pick SEND / HANDLER / NONE). This page keeps the before/after handler shape for old links.
```python
# Before — factory boilerplate
class CancelTaskHandler(cqrs.RequestHandler[CancelTask, None]):
def __init__(self, uow_factory: Callable[[], AbstractAsyncContextManager[IUoW]]) -> None:
self._uow_factory = uow_factory
async def handle(self, command: CancelTask) -> None:
async with self._uow_factory() as uow:
await uow.tasks.cancel(command.task_id)
await uow.commit()
```
```python
# After — live dependency; cleanup on CQRS scope exit
class CancelTaskHandler(cqrs.RequestHandler[CancelTask, None]):
def __init__(self, uow: IUoW) -> None:
self._uow = uow
async def handle(self, command: CancelTask) -> None:
await self._uow.tasks.cancel(command.task_id)
await self._uow.commit()
```
Enable that with `scope_strategy=ScopeStrategy.SEND` on bootstrap. `di`'s `scope="request"` alone does not open a CQRS scope.
# Commands and queries
# Request Handlers
## Overview
Request handlers process commands (write operations) and queries (read operations) in your CQRS application. They are the core of your business logic and are automatically resolved through the [Dependency Injection](https://mkdocs.python-cqrs.dev/latest/di/index.md) container configured in [Bootstrap](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md).
| Concept | Description | Can Emit Events |
| ------------ | ----------------------------------------- | ----------------- |
| **Commands** | Modify system state | ✅ Yes |
| **Queries** | Read data without side effects | ❌ No (typically) |
| **Handlers** | Implement business logic, resolved via DI | Depends on type |
Prerequisites
Before creating handlers, ensure you've configured [Bootstrap](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md) and understand [Dependency Injection](https://mkdocs.python-cqrs.dev/latest/di/index.md).
Related Topics
- [Request / Response Types](https://mkdocs.python-cqrs.dev/latest/request_response_types/index.md) — Different types for requests and responses (Pydantic, Dataclasses, attrs, etc.)
- [Stream Handling](https://mkdocs.python-cqrs.dev/latest/stream_handling/index.md) — For incremental processing
- [Chain of Responsibility](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/index.md) — For sequential handler chains
- [Saga Pattern](https://mkdocs.python-cqrs.dev/latest/saga/index.md) — For distributed transactions with compensation
- [Event Handling](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md) — For processing events emitted by command handlers
- [Request Handler Fallback](https://mkdocs.python-cqrs.dev/latest/request_handler/fallback/index.md) — Fallback mechanism for resilient command/query handling
Request handlers can be divided into two main types:
## Command Handler
Command Handler executes the received command. The logic of the handler may include, for example, modifying the state of the domain model. As a result of executing the command, an event may be produced to the broker.
| Aspect | Description |
| ---------------- | ---------------------------------------------------------------------- |
| **Purpose** | Execute write operations, modify system state |
| **Return Value** | Optional (can return `None` or a response) |
| **Events** | Can emit domain events via `events` property |
| **Handler Type** | `RequestHandler[Command, Response]` or `RequestHandler[Command, None]` |
Return Value
By default, the command handler does not return any result, but it is not mandatory. You can return a response if needed.
**Command Handler Characteristics**
- **Modifies state**: Changes the system's state
- **Can emit events**: Returns events through `events` property
- **Idempotent**: Should be idempotent when possible
- **Transactional**: Often wrapped in database transactions
```python
from cqrs.handlers.request import RequestHandler
from cqrs.models.event import Event
class JoinMeetingCommandHandler(RequestHandler[JoinMeetingCommand, None]):
def __init__(self, meetings_api: MeetingAPIProtocol) -> None:
self._meetings_api = meetings_api
self.events: list[Event] = []
@property
def events(self) -> typing.List[events.Event]:
return self._events
async def handle(self, request: JoinMeetingCommand) -> None:
await self._meetings_api.join_user(request.user_id, request.meeting_id)
```
## Query Handler
Query Handler returns a representation of the requested data, for example, from the [read model](https://radekmaziarka.pl/2018/01/08/cqrs-third-step-simple-read-model/#simple-read-model---to-the-rescue).
| Aspect | Description |
| ---------------- | ---------------------------------------- |
| **Purpose** | Read data without modifying system state |
| **Return Value** | Always returns a response |
| **Events** | Typically does not emit events |
| **Handler Type** | `RequestHandler[Query, QueryResponse]` |
Read Model
The read model can be constructed based on domain events produced by the `Command Handler`. This allows for optimized read operations.
**Query Handler Characteristics**
- **Read-only**: Does not modify system state
- **No side effects**: Should not have side effects
- **Optimized**: Can use read models optimized for specific queries
- **Fast**: Should be fast and efficient
```python
from cqrs.handlers.request import RequestHandler
from cqrs.models.event import Event
class ReadMeetingQueryHandler(RequestHandler[ReadMeetingQuery, ReadMeetingQueryResult]):
def __init__(self, meetings_api: MeetingAPIProtocol) -> None:
self._meetings_api = meetings_api
self.events: list[Event] = []
@property
def events(self) -> typing.List[events.Event]:
return self._events
async def handle(self, request: ReadMeetingQuery) -> ReadMeetingQueryResult:
link = await self._meetings_api.get_link(request.meeting_id)
return ReadMeetingQueryResult(link=link, meeting_id=request.meeting_id)
```
# Request Handler Fallback
- **Back to Request Handlers Overview**
Return to the Request Handlers overview page.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md)
______________________________________________________________________
## Overview
The **Request Handler Fallback** pattern allows you to register an alternative handler that runs when the primary request handler fails (or when the circuit breaker is open). This provides resilience for commands and queries when the primary path (e.g. database or external API) is unavailable.
| Concept | Description |
| ---------------------- | ------------------------------------------------------------------------------------ |
| **Primary handler** | Main request handler that executes first |
| **Fallback handler** | Alternative handler invoked on primary failure or circuit open |
| **failure_exceptions** | Optional tuple of exception types that trigger fallback; if empty, any exception |
| **Circuit Breaker** | Optional; after threshold failures, primary is not called, fallback runs immediately |
When to Use
Use Request Handler Fallback when you need resilient reads or writes: cached/default responses when the primary path fails, or when integrating with external services that may be temporarily unavailable.
## Registration
Bind the request type to `RequestHandlerFallback(primary, fallback, ...)` in your commands or queries mapper:
```python
import cqrs
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(
GetUserProfileCommand,
cqrs.RequestHandlerFallback(
primary=PrimaryGetUserProfileHandler,
fallback=FallbackGetUserProfileHandler,
failure_exceptions=(ConnectionError, TimeoutError), # optional
circuit_breaker=request_cb, # optional
),
)
```
- **primary** — The primary request handler class (`RequestHandler` or `StreamingRequestHandler`).
- **fallback** — The fallback handler class; must handle the same request/response types.
- **failure_exceptions** — If non-empty, only these exception types trigger fallback; otherwise any exception triggers fallback.
- **circuit_breaker** — Optional `ICircuitBreaker` instance (e.g. `AioBreakerAdapter`). Use one instance per domain (e.g. one for requests). When the circuit is open, the primary handler is not called and the fallback runs immediately.
## Basic Example
```python
class GetUserProfileCommand(cqrs.Request):
user_id: str
class UserProfileResult(cqrs.Response):
user_id: str
name: str
source: str # "primary" or "fallback"
class PrimaryGetUserProfileHandler(
cqrs.RequestHandler[GetUserProfileCommand, UserProfileResult],
):
@property
def events(self) -> list[cqrs.Event]:
return []
async def handle(self, request: GetUserProfileCommand) -> UserProfileResult:
# e.g. database call that may raise
raise ConnectionError("Database unavailable")
class FallbackGetUserProfileHandler(
cqrs.RequestHandler[GetUserProfileCommand, UserProfileResult],
):
@property
def events(self) -> list[cqrs.Event]:
return []
async def handle(self, request: GetUserProfileCommand) -> UserProfileResult:
return UserProfileResult(
user_id=request.user_id,
name="Unknown User",
source="fallback",
)
# In mapper:
mapper.bind(
GetUserProfileCommand,
cqrs.RequestHandlerFallback(
primary=PrimaryGetUserProfileHandler,
fallback=FallbackGetUserProfileHandler,
failure_exceptions=(ConnectionError, TimeoutError),
),
)
```
The mediator dispatches to the primary handler; on exception (or when the circuit is open), the fallback handler is invoked. The response and events from the handler that actually ran are returned.
| Strategy | Fallback UoW |
| ----------- | ------------------------------------------------------------------------------------------------------------------------ |
| **SEND** | Same unit of work as the primary (and domain events). After a DB error the session may need `rollback()` / a savepoint. |
| **HANDLER** | New scope. Primary rolls back (exception through the generator UoW), then fallback runs. Primary writes are not visible. |
| **NONE** | No framework scopes. |
There is no `fallback_shares_scope` flag — use SEND to share the transaction, HANDLER for a clean alternative path. See [Scope Strategies](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/strategies/index.md).
## Circuit Breaker (optional)
To use a circuit breaker, install the optional dependency and pass an adapter instance:
```bash
pip install aiobreaker
# or: pip install python-cqrs[aiobreaker]
```
```python
from cqrs.adapters.circuit_breaker import AioBreakerAdapter
request_cb = AioBreakerAdapter(fail_max=5, timeout_duration=60)
mapper.bind(
GetUserProfileCommand,
cqrs.RequestHandlerFallback(
primary=PrimaryGetUserProfileHandler,
fallback=FallbackGetUserProfileHandler,
failure_exceptions=(ConnectionError, TimeoutError),
circuit_breaker=request_cb,
),
)
```
After `fail_max` failures, the circuit opens: the primary handler is not called and the fallback runs immediately. See [Saga Fallback — Circuit Breaker](https://mkdocs.python-cqrs.dev/latest/saga/fallback/circuit_breaker/index.md) for the same three-state pattern (CLOSED / OPEN / HALF_OPEN).
### Circuit Breaker configuration
Adapter `AioBreakerAdapter` (from `cqrs.adapters.circuit_breaker`) implements `ICircuitBreaker` and accepts the following parameters:
| Parameter | Description | Default |
| ------------------ | ------------------------------------------------------------------------ | --------- |
| `fail_max` | Number of failures before opening the circuit | `5` |
| `timeout_duration` | Seconds to wait before attempting HALF_OPEN (retry) | `60` |
| `exclude` | Exception types that **do not** count as failures (e.g. business errors) | `[]` |
| `storage_factory` | Factory `(name: str) -> storage` for circuit state; default is in-memory | in-memory |
**Example with `exclude`** — business exceptions do not open the circuit:
```python
class UserNotFoundError(Exception):
pass
request_cb = AioBreakerAdapter(
fail_max=5,
timeout_duration=60,
exclude=[UserNotFoundError], # 404-style errors don't open the circuit
)
mapper.bind(
GetUserProfileCommand,
cqrs.RequestHandlerFallback(
primary=PrimaryGetUserProfileHandler,
fallback=FallbackGetUserProfileHandler,
circuit_breaker=request_cb,
),
)
```
**One instance per domain** — use a single `AioBreakerAdapter` instance for all request fallbacks that share the same policy (e.g. one for commands). The adapter creates an isolated circuit per handler type (identifier = handler class).
**Storage:** By default the circuit state is in-memory (per process). For distributed apps, you can pass a `storage_factory` that returns Redis (or other) storage so the circuit state is shared across instances. See [Saga Fallback — Circuit Breaker: Storage Configuration](https://mkdocs.python-cqrs.dev/latest/saga/fallback/circuit_breaker/#storage-configuration-memory-vs-redis).
**Failure filtering:** Use `failure_exceptions` on `RequestHandlerFallback` to restrict which exceptions trigger fallback; use `exclude` on `AioBreakerAdapter` to prevent certain exceptions from opening the circuit. Details: [Saga Fallback — Circuit Breaker: Failure Exception Filtering and Business Exception Exclusion](https://mkdocs.python-cqrs.dev/latest/saga/fallback/circuit_breaker/#failure-exception-filtering).
## Streaming Handlers
`RequestHandlerFallback` also supports **streaming handlers**. Both `primary` and `fallback` can be `StreamingRequestHandler`. When the primary stream raises (e.g. after yielding some items), the dispatcher switches to the fallback handler's stream. See [Stream Handling Fallback](https://mkdocs.python-cqrs.dev/latest/stream_handling/fallback/index.md) for details.
## Related
- [Event Handler Fallback](https://mkdocs.python-cqrs.dev/latest/event_handler/fallback/index.md) — Fallback for event handlers
- [Stream Handling Fallback](https://mkdocs.python-cqrs.dev/latest/stream_handling/fallback/index.md) — Fallback for streaming handlers
- [Chain of Responsibility](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/index.md) — Can be combined with fallback (COR as primary)
- [Saga Fallback Pattern](https://mkdocs.python-cqrs.dev/latest/saga/fallback/index.md) — Fallback for saga steps
# Request and Response Types
## Overview
The `python-cqrs` library provides flexible support for different types of request and response implementations. All request types must implement the `IRequest` interface, and all response types must implement the `IResponse` interface. This allows you to choose the best implementation for your specific use case.
Interface Requirements
Both `IRequest` and `IResponse` interfaces require:
- `to_dict()` method: Convert instance to dictionary
- `from_dict()` classmethod: Create instance from dictionary
## Navigation
- **Pydantic**
Default implementation with validation and serialization features.
[Read More](https://mkdocs.python-cqrs.dev/latest/request_response_types/pydantic/index.md)
- **Dataclasses**
Lightweight implementation using Python's standard library.
[Read More](https://mkdocs.python-cqrs.dev/latest/request_response_types/dataclasses/index.md)
- **Standard Classes**
Full control with custom Python classes.
[Read More](https://mkdocs.python-cqrs.dev/latest/request_response_types/standard_classes/index.md)
- **NamedTuple**
Immutable data structures with memory efficiency.
[Read More](https://mkdocs.python-cqrs.dev/latest/request_response_types/namedtuple/index.md)
- **Attrs**
Advanced features beyond standard dataclasses.
[Read More](https://mkdocs.python-cqrs.dev/latest/request_response_types/attrs/index.md)
- **Msgspec**
High-performance serialization for microservices.
[Read More](https://mkdocs.python-cqrs.dev/latest/request_response_types/msgspec/index.md)
- **TypedDict**
Type hints with zero runtime overhead.
[Read More](https://mkdocs.python-cqrs.dev/latest/request_response_types/typeddict/index.md)
- **Mixed Usage**
Combining different types for requests and responses.
[Read More](https://mkdocs.python-cqrs.dev/latest/request_response_types/mixed_usage/index.md)
- **Best Practices**
Recommendations for choosing and using types.
[Read More](https://mkdocs.python-cqrs.dev/latest/request_response_types/best_practices/index.md)
## Comparison Table
| Type | Validation | Performance | Dependencies | Best For |
| ---------------------------- | -------------------- | ----------- | ------------- | ------------------------------ |
| **PydanticRequest/Response** | ✅ Runtime | Medium | pydantic | Web APIs, validation needed |
| **DCRequest/DCResponse** | ❌ | Fast | None (stdlib) | Internal systems, lightweight |
| **Standard Classes** | Custom | Fast | None | Full control, custom logic |
| **NamedTuple** | ❌ | Very Fast | None (stdlib) | Immutable, memory-efficient |
| **Attrs** | ✅ (with validators) | Fast | attrs | More features than dataclasses |
| **Msgspec** | ✅ Runtime | Very Fast | msgspec | High-performance microservices |
| **TypedDict** | ❌ (static only) | Fastest | None (stdlib) | Type hints, zero overhead |
## Quick Decision Guide
### Need Validation?
- **Pydantic** — Best for web APIs with comprehensive validation
- **Msgspec** — High-performance alternative with validation
- **Attrs** — Custom validators with more flexibility
### Need Performance?
- **Msgspec** — Fastest serialization (Rust-based)
- **Dataclasses** — Fast, no dependencies
- **NamedTuple** — Very fast, immutable
### Need Lightweight?
- **Dataclasses** — Standard library, no dependencies
- **NamedTuple** — Minimal overhead
- **TypedDict** — Zero runtime overhead
### Need Flexibility?
- **Standard Classes** — Full control over implementation
- **Attrs** — Advanced features and customization
- **Pydantic** — Rich ecosystem and integrations
## Interface Contract
All request and response types must implement the following interface:
```python
from abc import ABC, abstractmethod
from typing import Self
class IRequest(ABC):
@abstractmethod
def to_dict(self) -> dict:
"""Convert instance to dictionary."""
raise NotImplementedError
@classmethod
@abstractmethod
def from_dict(cls, **kwargs) -> Self:
"""Create instance from dictionary."""
raise NotImplementedError
class IResponse(ABC):
@abstractmethod
def to_dict(self) -> dict:
"""Convert instance to dictionary."""
raise NotImplementedError
@classmethod
@abstractmethod
def from_dict(cls, **kwargs) -> Self:
"""Create instance from dictionary."""
raise NotImplementedError
```
## Built-in Types
The library provides two built-in implementations that you can use directly:
1. **PydanticRequest/PydanticResponse** (aliased as `Request`/`Response`)
1. Default implementation
1. Runtime validation
1. Type coercion
1. JSON Schema support
1. **DCRequest/DCResponse**
1. Lightweight dataclass-based
1. No external dependencies
1. Fast and simple
Prerequisites
Understanding of [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) is recommended. Request and response types are used in handler definitions.
Related Topics
- [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) — Learn how to use types in handlers
- [Bootstrap](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md) — Setup and configuration
- [Dependency Injection](https://mkdocs.python-cqrs.dev/latest/di/index.md) — DI container usage
# Attrs Request/Response
**Advanced features** - Uses the `attrs` library for more features than standard dataclasses.
## Best For
- More features than standard dataclasses
- Functional-style codebases
- When you need more control than dataclasses
- Complex validation scenarios
Installation
```bash
pip install attrs
```
## Usage
```python
import cqrs
import attrs
from typing import Self
@attrs.define
class AttrsRequest(cqrs.IRequest):
"""Request using attrs."""
user_id: str = attrs.field(validator=attrs.validators.instance_of(str))
action: str = attrs.field(validator=attrs.validators.instance_of(str))
def to_dict(self) -> dict:
"""Convert to dictionary."""
return attrs.asdict(self)
@classmethod
def from_dict(cls, **kwargs) -> Self:
"""Create from dictionary."""
return cls(**kwargs)
@attrs.define(frozen=True)
class AttrsResponse(cqrs.IResponse):
"""Immutable response using attrs."""
result: str = attrs.field()
status: str = attrs.field()
def to_dict(self) -> dict:
"""Convert to dictionary."""
return attrs.asdict(self)
@classmethod
def from_dict(cls, **kwargs) -> Self:
"""Create from dictionary."""
return cls(**kwargs)
class AttrsHandler(cqrs.RequestHandler[AttrsRequest, AttrsResponse]):
@property
def events(self) -> list[cqrs.IEvent]:
return []
async def handle(self, request: AttrsRequest) -> AttrsResponse:
return AttrsResponse(
result=f"Processed {request.action}",
status="success"
)
```
## Validation
Attrs provides powerful validation capabilities:
```python
import attrs
from typing import Self
@attrs.define
class ValidatedRequest(cqrs.IRequest):
email: str = attrs.field(
validator=attrs.validators.matches_re(r'^[\w\.-]+@[\w\.-]+\.\w+$')
)
age: int = attrs.field(
validator=attrs.validators.and_(
attrs.validators.ge(0),
attrs.validators.le(120)
)
)
def to_dict(self) -> dict:
return attrs.asdict(self)
@classmethod
def from_dict(cls, **kwargs) -> Self:
return cls(**kwargs)
```
## Custom Converters
You can use converters for automatic type conversion:
```python
@attrs.define
class ConvertedRequest(cqrs.IRequest):
count: int = attrs.field(converter=int)
tags: list[str] = attrs.field(
converter=lambda x: x.split(",") if isinstance(x, str) else x
)
def to_dict(self) -> dict:
return attrs.asdict(self)
@classmethod
def from_dict(cls, **kwargs) -> Self:
return cls(**kwargs)
```
## Factory Functions
Attrs supports factory functions for default values:
```python
@attrs.define
class RequestWithDefaults(cqrs.IRequest):
user_id: str
metadata: dict = attrs.field(factory=dict)
tags: list[str] = attrs.field(factory=list)
def to_dict(self) -> dict:
return attrs.asdict(self)
@classmethod
def from_dict(cls, **kwargs) -> Self:
return cls(**kwargs)
```
## Immutable Classes
Use `frozen=True` for immutability:
```python
@attrs.define(frozen=True)
class ImmutableRequest(cqrs.IRequest):
user_id: str
action: str
def to_dict(self) -> dict:
return attrs.asdict(self)
@classmethod
def from_dict(cls, **kwargs) -> Self:
return cls(**kwargs)
```
## See Also
- [Dataclasses](https://mkdocs.python-cqrs.dev/latest/request_response_types/dataclasses/index.md) - Standard library alternative
- [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) - Learn about handler implementation
# Best Practices
Recommendations for choosing and using request/response types in your CQRS application.
## Choosing the Right Type
### Decision Tree
```
graph TD
Start[Choose Request/Response Type] --> Q1{Need Validation?}
Q1 -->|Yes| Q1A{Need High Performance?}
Q1A -->|Yes| Msgspec[Msgspec]
Q1A -->|No| Pydantic[Pydantic]
Q1 -->|No| Q2{Need Performance?}
Q2 -->|Yes| Q2A{Need Lightweight?}
Q2A -->|Yes| Dataclasses1[Dataclasses]
Q2A -->|No| Msgspec2[Msgspec]
Q2 -->|No| Q3{Need Lightweight?}
Q3 -->|Yes| Q3A{Need Immutability?}
Q3A -->|Yes| NamedTuple[NamedTuple]
Q3A -->|No| Dataclasses2[Dataclasses]
Q3 -->|No| Q4{Need Flexibility?}
Q4 -->|Yes| Q4A{Need Advanced Features?}
Q4A -->|Yes| Attrs[Attrs]
Q4A -->|No| StandardClasses[Standard Classes]
Q4 -->|No| Dataclasses3[Dataclasses]
style Start fill:#e1f5ff,stroke:#01579b,stroke-width:4px
style Pydantic fill:#c8e6c9,stroke:#2e7d32,stroke-width:3px
style Msgspec fill:#fff3e0,stroke:#e65100,stroke-width:3px
style Msgspec2 fill:#fff3e0,stroke:#e65100,stroke-width:3px
style Dataclasses1 fill:#c8e6c9,stroke:#2e7d32,stroke-width:3px
style Dataclasses2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:3px
style Dataclasses3 fill:#c8e6c9,stroke:#2e7d32,stroke-width:3px
style NamedTuple fill:#e1f5ff,stroke:#01579b,stroke-width:3px
style Attrs fill:#fff3e0,stroke:#e65100,stroke-width:3px
style StandardClasses fill:#ffcdd2,stroke:#c62828,stroke-width:3px
```
**Type Descriptions:**
- **Pydantic** — Rich validation & features, best for web APIs
- **Msgspec** — High-performance with validation, up to 10x faster than Pydantic
- **Dataclasses** — Fast & lightweight, no dependencies, default choice
- **NamedTuple** — Immutable & memory-efficient
- **Attrs** — Advanced features beyond standard dataclasses
- **Standard Classes** — Full control over implementation
**Step-by-step guide:**
1. **Need Validation?**
1. ✅ Yes → Choose between **Pydantic** (rich features) or **Msgspec** (high performance)
1. ❌ No → Continue to step 2
1. **Need Performance?**
1. ✅ Yes → Choose **Msgspec** (fastest) or **Dataclasses** (fast & lightweight)
1. ❌ No → Continue to step 3
1. **Need Lightweight?**
1. ✅ Yes → Choose **Dataclasses** (simple) or **NamedTuple** (immutable)
1. ❌ No → Continue to step 4
1. **Need Flexibility?**
1. ✅ Yes → Choose **Standard Classes** (full control) or **Attrs** (advanced features)
1. ❌ No → Use **Dataclasses** (default choice)
### Quick Reference
| Requirement | Recommended Type |
| ------------------------------ | ---------------- |
| Web APIs with validation | Pydantic |
| High-performance microservices | Msgspec |
| Internal systems, lightweight | Dataclasses |
| Immutable, memory-efficient | NamedTuple |
| Advanced features | Attrs |
| Static type checking only | TypedDict |
| Full control | Standard Classes |
## Consistency Guidelines
### Within a Domain
Use the same type for related requests and responses:
```python
# ✅ Good: Consistent types
class CreateUserCommand(cqrs.PydanticRequest):
pass
class UserResponse(cqrs.PydanticResponse):
pass
# ❌ Avoid: Mixed types without reason
class CreateUserCommand(cqrs.PydanticRequest):
pass
class UserResponse(cqrs.DCResponse): # Why different?
pass
```
### Across Domains
Different domains can use different types based on their needs:
```python
# User domain - needs validation
class CreateUserCommand(cqrs.PydanticRequest):
pass
# Analytics domain - performance critical
class ProcessEventRequest(cqrs.IRequest, msgspec.Struct):
pass
```
## Validation Strategy
### External Input
Always validate external input:
```python
# ✅ Good: Validate external input
class CreateUserCommand(cqrs.PydanticRequest):
email: str = pydantic.Field(pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')
age: int = pydantic.Field(gt=0, le=120)
```
### Internal Communication
Internal communication can use lighter types:
```python
# ✅ Good: Lightweight for internal use
@dataclasses.dataclass
class InternalQuery(cqrs.DCRequest):
user_id: str
```
## Performance Considerations
### High-Throughput Scenarios
For high-throughput scenarios, consider Msgspec:
```python
# High-performance request
class ProcessRequest(cqrs.IRequest, msgspec.Struct):
data: str
```
### Simple Data Structures
For simple data, Dataclasses are fast enough:
```python
@dataclasses.dataclass
class SimpleRequest(cqrs.DCRequest):
id: str
value: int
```
## Migration Strategy
### Gradual Migration
All types implement the same interface, so migration is straightforward:
```python
# Step 1: Start with Pydantic
class MyCommand(cqrs.PydanticRequest):
pass
# Step 2: Migrate to Dataclass
@dataclasses.dataclass
class MyCommand(cqrs.DCRequest):
pass
```
### Testing Migration
Test thoroughly when migrating:
```python
# Test that serialization works
def test_migration():
command = MyCommand(id="123")
data = command.to_dict()
restored = MyCommand.from_dict(**data)
assert restored.id == "123"
```
## Common Patterns
### Pattern 1: Validate Input, Optimize Output
```python
# Validate external input
class CreateOrderCommand(cqrs.PydanticRequest):
user_id: str
items: list[str]
# Lightweight response
@dataclasses.dataclass
class OrderResponse(cqrs.DCResponse):
order_id: str
```
### Pattern 2: Performance-Critical
```python
# High-performance request
class ProcessRequest(cqrs.IRequest, msgspec.Struct):
data: str
# Simple response
@dataclasses.dataclass
class ProcessResponse(cqrs.DCResponse):
result: str
```
### Pattern 3: Full Control
```python
# Custom validation and serialization
class CustomRequest(cqrs.IRequest):
def __init__(self, data: str):
if not data:
raise ValueError("Data cannot be empty")
self.data = data
def to_dict(self) -> dict:
return {"data": self.data}
@classmethod
def from_dict(cls, **kwargs) -> Self:
return cls(**kwargs)
```
## Anti-Patterns
### ❌ Over-Engineering
Don't use complex types for simple use cases:
```python
# ❌ Over-engineered
class SimpleQuery(cqrs.IRequest, msgspec.Struct):
id: str
# ✅ Simple enough
@dataclasses.dataclass
class SimpleQuery(cqrs.DCRequest):
id: str
```
### ❌ Inconsistent Types
Don't mix types without reason:
```python
# ❌ Inconsistent
class CreateCommand(cqrs.PydanticRequest):
pass
class UpdateCommand(cqrs.DCRequest): # Why different?
pass
```
### ❌ Skipping Validation
Don't skip validation for external input:
```python
# ❌ No validation for external input
@dataclasses.dataclass
class CreateUserCommand(cqrs.DCRequest):
email: str # No validation!
# ✅ Validate external input
class CreateUserCommand(cqrs.PydanticRequest):
email: str = pydantic.Field(pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')
```
## Documentation
### Document Your Choices
Document why you chose a specific type:
```python
# Use Pydantic for external API requests to ensure validation
class CreateUserCommand(cqrs.PydanticRequest):
"""Uses Pydantic for runtime validation of external input."""
email: str
```
### Team Guidelines
Create team guidelines for type selection:
```markdown
## Type Selection Guidelines
- External APIs: Use Pydantic
- Internal communication: Use Dataclasses
- High-performance: Use Msgspec
- Custom logic: Use Standard Classes
```
## See Also
- [Comparison Table](https://mkdocs.python-cqrs.dev/latest/request_response_types/#comparison-table) - Detailed comparison
- [Mixed Usage](https://mkdocs.python-cqrs.dev/latest/request_response_types/mixed_usage/index.md) - Combining different types
- [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) - Handler implementation
# Dataclasses Request/Response
**Lightweight implementation** - Uses Python's standard library `dataclasses`.
## Best For
- When you want to avoid Pydantic dependency
- Internal system communication
- Performance-critical paths
- Simple data structures
## Features
- No external dependencies (beyond standard library)
- Fast and lightweight
- Simple and straightforward
- No runtime validation (by default)
## Usage
```python
import dataclasses
import cqrs
@dataclasses.dataclass
class CreateProductCommand(cqrs.DCRequest):
"""Dataclass-based command."""
name: str
price: float
category: str
@dataclasses.dataclass
class ProductResponse(cqrs.DCResponse):
"""Dataclass-based response."""
product_id: str
name: str
price: float
category: str
class CreateProductHandler(
cqrs.RequestHandler[CreateProductCommand, ProductResponse]
):
@property
def events(self) -> list[cqrs.IEvent]:
return []
async def handle(
self, request: CreateProductCommand
) -> ProductResponse:
product_id = f"product_{request.name}"
return ProductResponse(
product_id=product_id,
name=request.name,
price=request.price,
category=request.category
)
```
## Frozen Dataclasses
You can use frozen dataclasses for immutability:
```python
@dataclasses.dataclass(frozen=True)
class ImmutableRequest(cqrs.DCRequest):
user_id: str
action: str
# This will raise FrozenInstanceError
request = ImmutableRequest(user_id="123", action="test")
# request.user_id = "456 # ❌ Error: cannot assign to field
```
## Default Values
Dataclasses support default values and factory functions:
```python
@dataclasses.dataclass
class CreateOrderCommand(cqrs.DCRequest):
user_id: str
product_id: str
quantity: int = 1
tags: list[str] = dataclasses.field(default_factory=list)
```
## Serialization
```python
command = CreateProductCommand(
name="Laptop",
price=999.99,
category="Electronics"
)
# Convert to dictionary
data = command.to_dict()
# {"name": "Laptop", "price": 999.99, "category": "Electronics"}
# Restore from dictionary
restored = CreateProductCommand.from_dict(**data)
```
## See Also
- [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) - Learn about handler implementation
- [Mixed Usage](https://mkdocs.python-cqrs.dev/latest/request_response_types/mixed_usage/index.md) - Combining Dataclasses with other types
# Mixed Usage
You can mix different types for requests and responses based on your needs. This flexibility allows you to choose the best type for each use case.
## Examples
### Pydantic Request with Dataclass Response
Use Pydantic for request validation, but lightweight dataclass for response:
```python
import dataclasses
import cqrs
import pydantic
# Pydantic request with validation
class CreateOrderCommand(cqrs.PydanticRequest):
user_id: str
product_id: str
quantity: int = pydantic.Field(gt=0)
# Dataclass response - lightweight
@dataclasses.dataclass
class OrderResponse(cqrs.DCResponse):
order_id: str
total_price: float
class CreateOrderHandler(
cqrs.RequestHandler[CreateOrderCommand, OrderResponse]
):
@property
def events(self) -> list[cqrs.IEvent]:
return []
async def handle(self, request: CreateOrderCommand) -> OrderResponse:
# Request is validated by Pydantic
total_price = 99.99 * request.quantity
return OrderResponse(
order_id=f"order_{request.user_id}",
total_price=total_price
)
```
### Dataclass Request with Pydantic Response
Use lightweight dataclass for request, but Pydantic for response validation:
```python
@dataclasses.dataclass
class GetUserQuery(cqrs.DCRequest):
"""Dataclass query - simple and lightweight."""
user_id: str
class UserDetailsResponse(cqrs.PydanticResponse):
"""Pydantic response with validation."""
user_id: str
username: str
email: str
total_orders: int = 0
class GetUserQueryHandler(
cqrs.RequestHandler[GetUserQuery, UserDetailsResponse]
):
@property
def events(self) -> list[cqrs.IEvent]:
return []
async def handle(self, request: GetUserQuery) -> UserDetailsResponse:
# Response is validated by Pydantic
return UserDetailsResponse(
user_id=request.user_id,
username="john",
email="john@example.com",
total_orders=5
)
```
### Msgspec Request with Dataclass Response
High-performance request with lightweight response:
```python
import msgspec
class ProcessDataRequest(cqrs.IRequest, msgspec.Struct):
data: str
options: dict
def to_dict(self) -> dict:
return msgspec.to_builtins(self)
@classmethod
def from_dict(cls, **kwargs) -> Self:
return msgspec.from_builtins(cls, kwargs)
@dataclasses.dataclass
class ProcessDataResponse(cqrs.DCResponse):
result: str
processed_at: str
```
## Best Practices for Mixed Usage
1. **Validate Input, Optimize Output**
1. Use Pydantic/Msgspec for requests (external input)
1. Use Dataclasses for responses (internal data)
1. **Performance-Critical Paths**
1. Use Msgspec for high-throughput requests
1. Use Dataclasses for simple responses
1. **Consistency Within Domains**
1. Keep the same type for related requests/responses
1. Document your choices in team guidelines
1. **Migration Strategy**
1. Start with one type, migrate gradually
1. All types implement the same interface
## See Also
- [Best Practices](https://mkdocs.python-cqrs.dev/latest/request_response_types/best_practices/index.md) - Recommendations for choosing types
- [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) - Learn about handler implementation
# Msgspec Request/Response
**High-performance** - Uses `msgspec` for blazing-fast serialization (up to 10x faster than Pydantic).
## Best For
- High-performance microservices
- When speed is critical (up to 10x faster than Pydantic)
- JSON and MsgPack serialization
- WebSocket applications
Installation
```bash
pip install msgspec
```
Performance
Msgspec is written in Rust and provides significantly better performance than Pydantic for serialization/deserialization.
## Usage
```python
import cqrs
import msgspec
from typing import Self
class MsgspecRequest(cqrs.IRequest, msgspec.Struct):
"""Request using msgspec.Struct."""
user_id: str
action: str
def to_dict(self) -> dict:
"""Convert to dictionary."""
return msgspec.to_builtins(self)
@classmethod
def from_dict(cls, **kwargs) -> Self:
"""Create from dictionary."""
return msgspec.from_builtins(cls, kwargs)
class MsgspecResponse(cqrs.IResponse, msgspec.Struct):
"""Response using msgspec.Struct."""
result: str
status: str
def to_dict(self) -> dict:
"""Convert to dictionary."""
return msgspec.to_builtins(self)
@classmethod
def from_dict(cls, **kwargs) -> Self:
"""Create from dictionary."""
return msgspec.from_builtins(cls, kwargs)
class MsgspecHandler(cqrs.RequestHandler[MsgspecRequest, MsgspecResponse]):
@property
def events(self) -> list[cqrs.IEvent]:
return []
async def handle(self, request: MsgspecRequest) -> MsgspecResponse:
return MsgspecResponse(
result=f"Processed {request.action}",
status="success"
)
```
## Validation
Msgspec provides runtime validation:
```python
import msgspec
class ValidatedRequest(cqrs.IRequest, msgspec.Struct):
email: str = msgspec.field()
age: int = msgspec.field(ge=0, le=120)
def to_dict(self) -> dict:
return msgspec.to_builtins(self)
@classmethod
def from_dict(cls, **kwargs) -> Self:
return msgspec.from_builtins(cls, kwargs)
```
## Frozen Structs
You can create immutable structs:
```python
class ImmutableRequest(cqrs.IRequest, msgspec.Struct, frozen=True):
user_id: str
action: str
def to_dict(self) -> dict:
return msgspec.to_builtins(self)
@classmethod
def from_dict(cls, **kwargs) -> Self:
return msgspec.from_builtins(cls, kwargs)
```
## Performance Comparison
Msgspec is significantly faster than Pydantic for serialization:
| Operation | Pydantic | Msgspec | Speedup |
| --------------- | -------- | ------- | ------------ |
| Serialization | 1x | ~5-10x | 5-10x faster |
| Deserialization | 1x | ~5-10x | 5-10x faster |
## See Also
- [Pydantic](https://mkdocs.python-cqrs.dev/latest/request_response_types/pydantic/index.md) - Alternative with more features
- [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) - Learn about handler implementation
# NamedTuple Request/Response
**Immutable and memory-efficient** - Uses Python's `NamedTuple` for lightweight data structures.
## Best For
- Immutable data structures
- Memory efficiency
- Simple, fixed configurations
- When immutability is required
Limitations
NamedTuple is immutable and has limited flexibility. Consider using dataclasses with `frozen=True` for similar benefits with more features. NamedTuple cannot directly inherit from `IRequest`/`IResponse`, so a wrapper class is needed.
## Usage
Since `NamedTuple` cannot directly inherit from `IRequest`/`IResponse`, we use a wrapper class:
```python
import cqrs
from typing import NamedTuple, Self
# Define the NamedTuple structure
_NamedTupleRequestBase = NamedTuple(
"NamedTupleRequestBase",
[("user_id", str), ("action", str)]
)
class NamedTupleRequest(cqrs.IRequest):
"""Request using NamedTuple as internal storage."""
def __init__(self, user_id: str, action: str):
self._data = _NamedTupleRequestBase(user_id=user_id, action=action)
@property
def user_id(self) -> str:
return self._data.user_id
@property
def action(self) -> str:
return self._data.action
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
"user_id": self._data.user_id,
"action": self._data.action
}
@classmethod
def from_dict(cls, **kwargs) -> Self:
"""Create from dictionary."""
return cls(
user_id=kwargs["user_id"],
action=kwargs["action"]
)
# Define the NamedTuple structure for response
_NamedTupleResponseBase = NamedTuple(
"NamedTupleResponseBase",
[("result", str), ("status", str)]
)
class NamedTupleResponse(cqrs.IResponse):
"""Response using NamedTuple as internal storage."""
def __init__(self, result: str, status: str):
self._data = _NamedTupleResponseBase(result=result, status=status)
@property
def result(self) -> str:
return self._data.result
@property
def status(self) -> str:
return self._data.status
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
"result": self._data.result,
"status": self._data.status
}
@classmethod
def from_dict(cls, **kwargs) -> Self:
"""Create from dictionary."""
return cls(
result=kwargs["result"],
status=kwargs["status"]
)
```
## Alternative: Using Class-Based NamedTuple
You can also use the class-based syntax:
```python
from typing import NamedTuple, Self
class NamedTupleRequestData(NamedTuple):
user_id: str
action: str
class NamedTupleRequest(cqrs.IRequest):
def __init__(self, user_id: str, action: str):
self._data = NamedTupleRequestData(user_id=user_id, action=action)
@property
def user_id(self) -> str:
return self._data.user_id
@property
def action(self) -> str:
return self._data.action
def to_dict(self) -> dict:
return self._data._asdict()
@classmethod
def from_dict(cls, **kwargs) -> Self:
return cls(**kwargs)
```
## When to Use
Consider using NamedTuple when:
- You need immutability guarantees
- Memory usage is critical
- You have simple, fixed data structures
- You want tuple-like behavior with named fields
## See Also
- [Dataclasses](https://mkdocs.python-cqrs.dev/latest/request_response_types/dataclasses/index.md) - Similar features with more flexibility
- [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) - Learn about handler implementation
# Pydantic Request/Response
**Default implementation** - Uses Pydantic v2 for validation and serialization.
## Best For
- Web APIs (especially with FastAPI)
- When you need runtime validation
- Complex data transformations
- JSON Schema generation
## Features
- Automatic validation
- Type coercion
- Excellent developer experience
- JSON Schema support
## Usage
```python
import cqrs
import pydantic
class CreateUserCommand(cqrs.PydanticRequest):
"""Pydantic-based command with validation."""
username: str
email: str = pydantic.Field(pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')
age: int = pydantic.Field(gt=0, le=120)
class UserResponse(cqrs.PydanticResponse):
"""Pydantic-based response."""
user_id: str
username: str
email: str
age: int
class CreateUserHandler(cqrs.RequestHandler[CreateUserCommand, UserResponse]):
@property
def events(self) -> list[cqrs.IEvent]:
return []
async def handle(self, request: CreateUserCommand) -> UserResponse:
# Validation happens automatically
user_id = f"user_{request.username}"
return UserResponse(
user_id=user_id,
username=request.username,
email=request.email,
age=request.age
)
```
## Aliases
For convenience, `PydanticRequest` and `PydanticResponse` are aliased as `Request` and `Response`:
```python
import cqrs
# These are equivalent:
class MyCommand(cqrs.Request): # Same as cqrs.PydanticRequest
pass
class MyResponse(cqrs.Response): # Same as cqrs.PydanticResponse
pass
```
## Validation
Pydantic provides automatic validation based on type hints and field validators:
```python
import cqrs
import pydantic
class CreateProductCommand(cqrs.PydanticRequest):
name: str = pydantic.Field(min_length=1, max_length=100)
price: float = pydantic.Field(gt=0)
category: str = pydantic.Field(pattern=r'^[A-Z][a-z]+$')
# This will raise ValidationError
try:
command = CreateProductCommand(
name="", # Too short
price=-10, # Negative
category="invalid" # Doesn't match pattern
)
except pydantic.ValidationError as e:
print(e)
```
## Serialization
Pydantic provides built-in serialization methods:
```python
command = CreateUserCommand(
username="john",
email="john@example.com",
age=30
)
# Convert to dictionary
data = command.to_dict()
# {"username": "john", "email": "john@example.com", "age": 30}
# Restore from dictionary
restored = CreateUserCommand.from_dict(**data)
```
## See Also
- [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) - Learn about handler implementation
- [Mixed Usage](https://mkdocs.python-cqrs.dev/latest/request_response_types/mixed_usage/index.md) - Combining Pydantic with other types
# Standard Python Classes
**Full control** - Implement `IRequest`/`IResponse` directly with standard Python classes.
## Best For
- Full control over serialization
- Custom validation logic
- Integration with existing codebases
- Minimal dependencies
## Usage
```python
import cqrs
from typing import Self
class CustomRequest(cqrs.IRequest):
"""Custom request using standard Python class."""
def __init__(self, user_id: str, action: str):
self.user_id = user_id
self.action = action
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
"user_id": self.user_id,
"action": self.action
}
@classmethod
def from_dict(cls, **kwargs) -> Self:
"""Create from dictionary."""
return cls(
user_id=kwargs["user_id"],
action=kwargs["action"]
)
class CustomResponse(cqrs.IResponse):
"""Custom response using standard Python class."""
def __init__(self, result: str, status: str):
self.result = result
self.status = status
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
"result": self.result,
"status": self.status
}
@classmethod
def from_dict(cls, **kwargs) -> Self:
"""Create from dictionary."""
return cls(
result=kwargs["result"],
status=kwargs["status"]
)
class CustomHandler(cqrs.RequestHandler[CustomRequest, CustomResponse]):
@property
def events(self) -> list[cqrs.IEvent]:
return []
async def handle(self, request: CustomRequest) -> CustomResponse:
return CustomResponse(
result=f"Processed {request.action} for {request.user_id}",
status="success"
)
```
## Custom Validation
You can add custom validation logic in `from_dict`:
```python
class ValidatedRequest(cqrs.IRequest):
def __init__(self, user_id: str, age: int):
if age < 0 or age > 150:
raise ValueError("Age must be between 0 and 150")
self.user_id = user_id
self.age = age
def to_dict(self) -> dict:
return {
"user_id": self.user_id,
"age": self.age
}
@classmethod
def from_dict(cls, **kwargs) -> Self:
return cls(**kwargs)
```
## Complex Serialization
You can implement complex serialization logic:
```python
import json
from datetime import datetime
from typing import Self
class TimestampedRequest(cqrs.IRequest):
def __init__(self, data: str, timestamp: datetime | None = None):
self.data = data
self.timestamp = timestamp or datetime.now()
def to_dict(self) -> dict:
return {
"data": self.data,
"timestamp": self.timestamp.isoformat()
}
@classmethod
def from_dict(cls, **kwargs) -> Self:
timestamp = kwargs.get("timestamp")
if isinstance(timestamp, str):
timestamp = datetime.fromisoformat(timestamp)
return cls(
data=kwargs["data"],
timestamp=timestamp
)
```
## See Also
- [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) - Learn about handler implementation
- [Best Practices](https://mkdocs.python-cqrs.dev/latest/request_response_types/best_practices/index.md) - Recommendations for custom implementations
# TypedDict Request/Response
**Type hints only** - Uses `TypedDict` for static type checking with zero runtime overhead.
## Best For
- Type hints for dictionaries
- Static type checking (mypy)
- When you need zero runtime overhead
- Integration with existing dict-based code
No Runtime Validation
TypedDict is only for static type checking. It doesn't provide runtime validation or instance creation. You'll need to implement `to_dict()` and `from_dict()` manually. TypedDict cannot directly implement `IRequest`/`IResponse`, so a wrapper class is required.
## Usage
Since `TypedDict` cannot directly implement `IRequest`/`IResponse`, we use a wrapper class:
```python
import cqrs
from typing import TypedDict, Self
# Define TypedDict for type hints
class TypedDictRequestDict(TypedDict):
"""Type definition for request dictionary."""
user_id: str
action: str
class TypedDictRequest(cqrs.IRequest):
"""Request using TypedDict for type hints."""
def __init__(self, user_id: str, action: str):
self.user_id = user_id
self.action = action
def to_dict(self) -> TypedDictRequestDict:
"""Convert to dictionary."""
return {
"user_id": self.user_id,
"action": self.action
}
@classmethod
def from_dict(cls, **kwargs) -> Self:
"""Create from dictionary."""
return cls(
user_id=kwargs["user_id"],
action=kwargs["action"]
)
class TypedDictResponseDict(TypedDict):
"""Type definition for response dictionary."""
result: str
status: str
class TypedDictResponse(cqrs.IResponse):
"""Response using TypedDict for type hints."""
def __init__(self, result: str, status: str):
self.result = result
self.status = status
def to_dict(self) -> TypedDictResponseDict:
"""Convert to dictionary."""
return {
"result": self.result,
"status": self.status
}
@classmethod
def from_dict(cls, **kwargs) -> Self:
"""Create from dictionary."""
return cls(
result=kwargs["result"],
status=kwargs["status"]
)
```
## Optional Fields
You can use `NotRequired` for optional fields:
```python
from typing import TypedDict, NotRequired
class OptionalFieldsDict(TypedDict):
user_id: str
action: str
metadata: NotRequired[dict]
class OptionalFieldsRequest(cqrs.IRequest):
def __init__(self, user_id: str, action: str, metadata: dict | None = None):
self.user_id = user_id
self.action = action
self.metadata = metadata or {}
def to_dict(self) -> OptionalFieldsDict:
result: OptionalFieldsDict = {
"user_id": self.user_id,
"action": self.action
}
if self.metadata:
result["metadata"] = self.metadata
return result
@classmethod
def from_dict(cls, **kwargs) -> Self:
return cls(
user_id=kwargs["user_id"],
action=kwargs["action"],
metadata=kwargs.get("metadata")
)
```
## Total vs Non-Total
You can control whether all fields are required:
```python
# Total=True (default) - all fields required
class TotalDict(TypedDict, total=True):
user_id: str
action: str
# Total=False - all fields optional
class NonTotalDict(TypedDict, total=False):
user_id: str
action: str
```
## When to Use
Consider using TypedDict when:
- You need static type checking only
- Runtime overhead must be zero
- You're working with existing dict-based code
- You want type hints without runtime validation
## See Also
- [Standard Classes](https://mkdocs.python-cqrs.dev/latest/request_response_types/standard_classes/index.md) - Similar approach with more flexibility
- [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) - Learn about handler implementation
# Streaming and chains
# Stream Handling
Stream handling allows you to process requests incrementally and yield results as they become available. This is particularly useful for processing large batches of items, file uploads, or any operation that benefits from real-time progress updates.
## Overview
- **Configuration**
Bootstrap setup and mediator usage for streaming requests.
[Read More](https://mkdocs.python-cqrs.dev/latest/stream_handling/configuration/index.md)
- **FastAPI Integration**
SSE integration examples for real-time progress updates.
[Read More](https://mkdocs.python-cqrs.dev/latest/stream_handling/fastapi_integration/index.md)
- **Reference**
Key features, use cases, and best practices for stream handling.
[Read More](https://mkdocs.python-cqrs.dev/latest/stream_handling/reference/index.md)
- **Fallback**
Fallback streaming handler when primary stream fails.
[Read More](https://mkdocs.python-cqrs.dev/latest/stream_handling/fallback/index.md)
`StreamingRequestHandler` works with `StreamingRequestMediator` to process requests incrementally. The handler yields results as they become available, and events are processed after each yield. This enables:
- **Real-time progress updates** — Clients receive results as they're processed
- **Better user experience** — No need to wait for entire batch to complete
- **Parallel event processing** — Events can be processed concurrently while streaming
- **SSE integration** — Perfect for Server-Sent Events in web applications
Prerequisites
Understanding of [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) and [Bootstrap](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md) is recommended.
Use Cases
Streaming is ideal for large batch operations, file processing, or any scenario where you want to provide real-time feedback. See [FastAPI Integration](https://mkdocs.python-cqrs.dev/latest/stream_handling/fastapi_integration/index.md) for SSE examples.
## Basic Example
Here's a simple example of a streaming handler:
```python
import typing
from datetime import datetime
import cqrs
from cqrs.handlers.request import StreamingRequestHandler
from cqrs.models.event import Event
class ProcessFilesCommand(cqrs.Request):
file_ids: list[str]
class FileProcessedResult(cqrs.Response):
file_id: str
status: str
processed_at: datetime
class ProcessFilesCommandHandler(
StreamingRequestHandler[ProcessFilesCommand, FileProcessedResult]
):
def __init__(self):
self._events: list[Event] = []
@property
def events(self) -> list[Event]:
return self._events.copy()
def clear_events(self) -> None:
self._events.clear()
async def handle(
self, request: ProcessFilesCommand
) -> typing.AsyncIterator[FileProcessedResult]:
for file_id in request.file_ids:
# Process file
result = FileProcessedResult(
file_id=file_id,
status="completed",
processed_at=datetime.now()
)
# Emit events
self._events.append(
FileProcessedEvent(file_id=file_id, ...)
)
# Yield result - events will be processed after this yield
yield result
```
Typing
The base `StreamingRequestHandler` declares `def handle(...) -> AsyncIterator[ResT]`. Your subclass implements it as an async generator (`async def handle(...): yield ...`). This is type-safe; no `# type: ignore` is needed. Call `mediator.stream(request)` **without** `await` and consume the result with `async for`.
# Configuration
- **Back to Stream Handling Overview**
Return to the Stream Handling overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/stream_handling/index.md)
______________________________________________________________________
## Overview
To use streaming handlers, you need to bootstrap a `StreamingRequestMediator`:
```python
import functools
from cqrs.bootstrap import requests as bootstrap
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(ProcessFilesCommand, ProcessFilesCommandHandler)
def domain_events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(FileProcessedEvent, FileProcessedEventHandler)
@functools.cache
def streaming_mediator_factory() -> cqrs.StreamingRequestMediator:
return bootstrap.bootstrap_streaming(
di_container=container,
commands_mapper=commands_mapper,
domain_events_mapper=domain_events_mapper,
message_broker=broker,
max_concurrent_event_handlers=3, # Process up to 3 events in parallel
concurrent_event_handle_enable=True, # Enable parallel processing (HANDLER / NONE)
)
```
`StreamingRequestMediator(..., scope_strategy=ScopeStrategy.SEND)` no longer needs a manual `concurrent_event_handle_enable=False`: `None` becomes `False` under SEND. Do not pass `True` with SEND.
```python
from cqrs import ScopeStrategy
mediator = bootstrap.bootstrap_streaming(
di_container=container,
commands_mapper=commands_mapper,
domain_events_mapper=domain_events_mapper,
scope_strategy=ScopeStrategy.SEND, # sequential events out of the box
)
```
Once you have a streaming mediator, you can stream results. Exhaust the iterator or close it with `aclose()` / `async with aclosing(...)`. An abandoned SEND stream holds the UoW until garbage collection.
```python
from contextlib import aclosing
mediator = streaming_mediator_factory()
command = ProcessFilesCommand(file_ids=["file1", "file2", "file3"])
async with aclosing(mediator.stream(command)) as stream:
async for result in stream:
if result is not None:
print(f"Processed: {result.file_id} - {result.status}")
```
Streaming handlers support parallel event processing. After each yield, events are collected and can be processed concurrently:
```python
class ProcessOrdersCommandHandler(
cqrs.StreamingRequestHandler[ProcessOrdersCommand, OrderProcessedResult]
):
def __init__(self):
self._events: list[Event] = []
@property
def events(self) -> list[Event]:
return self._events.copy()
def clear_events(self) -> None:
self._events.clear()
async def handle(
self, request: ProcessOrdersCommand
) -> typing.AsyncIterator[OrderProcessedResult]:
for order_id in request.order_ids:
# Process order
result = OrderProcessedResult(order_id=order_id, ...)
# Emit multiple events that will be processed in parallel
self._events.append(OrderProcessedEvent(order_id=order_id, ...))
self._events.append(OrderAnalyticsEvent(order_id=order_id, ...))
self._events.append(InventoryUpdateEvent(order_id=order_id, ...))
self._events.append(AuditLogEvent(order_id=order_id, ...))
yield result
# Events are processed in parallel after each yield
```
### Configuration
Control parallel event processing with these parameters:
- **`max_concurrent_event_handlers`** — Maximum number of event handlers that can run simultaneously (default: `10` for streaming mediator)
- **`concurrent_event_handle_enable`** — `None` by default: `False` under SEND (sequential BFS), `True` otherwise. Explicit `True` with SEND raises `ValueError`.
```python
from cqrs import ScopeStrategy
mediator = bootstrap.bootstrap_streaming(
di_container=container,
commands_mapper=commands_mapper,
domain_events_mapper=domain_events_mapper,
scope_strategy=ScopeStrategy.HANDLER, # or omit for NONE; not SEND
max_concurrent_event_handlers=5, # Process up to 5 events in parallel
concurrent_event_handle_enable=True, # Enable parallel processing
)
```
Configuration Tips
- Set `max_concurrent_event_handlers` to limit resource consumption
- Omit `concurrent_event_handle_enable` under SEND (`None` → `False`); do not pass `True`
- For parallel events use `HANDLER` or `NONE` plus `concurrent_event_handle_enable=True`
- Higher concurrency improves performance but uses more resources
- Always exhaust or `aclose()` / `aclosing` the stream so SEND UoW is released
# Stream Handling Fallback
- **Back to Stream Handling Overview**
Return to the Stream Handling overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/stream_handling/index.md)
______________________________________________________________________
## Overview
The **Stream Handling Fallback** uses the same `RequestHandlerFallback` wrapper as ordinary request handlers, but with **streaming handlers** as primary and fallback. When the primary streaming handler fails (e.g. raises after yielding some items), the dispatcher switches to the fallback streaming handler and consumes its stream. This is useful when the primary stream source (e.g. live API) can fail mid-way and you want to continue with a fallback stream (e.g. cached or degraded results).
| Concept | Description |
| ------------------------- | ----------------------------------------------------------------------------------------- |
| **Primary** | `StreamingRequestHandler` that yields first; may raise during iteration |
| **Fallback** | `StreamingRequestHandler` used when primary raises |
| **Flow** | `mediator.stream(request)` yields from primary until it raises, then yields from fallback |
| **Already yielded items** | Stay with the client; fallback does not rewind them. Only the unit of work changes. |
When to Use
Use streaming fallback when you stream results from a primary source that can fail partway through (e.g. connection lost). The fallback can yield from cache or a degraded path so the client still receives a complete stream. Always exhaust or `aclose()` / `async with aclosing(...)` the mediator stream; an abandoned SEND stream holds the UoW until GC.
| Strategy | Fallback UoW |
| ----------- | -------------------------------------------------------------------------------------------- |
| **SEND** | Same session as the primary. After a DB error call `rollback()` / savepoint, or use HANDLER. |
| **HANDLER** | New scope after the primary is rolled back (exception through the generator). |
| **NONE** | No framework scopes. |
## Registration
Bind the streaming command to `RequestHandlerFallback` with both handlers as `StreamingRequestHandler`:
```python
import cqrs
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(
StreamItemsCommand,
cqrs.RequestHandlerFallback(
primary=PrimaryStreamItemsHandler,
fallback=FallbackStreamItemsHandler,
failure_exceptions=(ConnectionError, TimeoutError), # optional
circuit_breaker=stream_cb, # optional
),
)
```
Use `bootstrap_streaming` to obtain a `StreamingRequestMediator`; when you call `mediator.stream(request)`, the dispatcher runs the primary handler's async generator. If the primary raises, the fallback handler's stream is used.
## Basic Example
```python
from typing import AsyncIterator
import cqrs
from cqrs.handlers.request import StreamingRequestHandler
class StreamItemsCommand(cqrs.Request):
item_ids: list[str]
class StreamItemResult(cqrs.Response):
item_id: str
status: str
source: str # "primary" or "fallback"
class PrimaryStreamItemsHandler(
cqrs.StreamingRequestHandler[StreamItemsCommand, StreamItemResult],
):
def __init__(self) -> None:
self._events: list[cqrs.Event] = []
@property
def events(self) -> list[cqrs.Event]:
return self._events.copy()
def clear_events(self) -> None:
self._events.clear()
async def handle(
self, request: StreamItemsCommand
) -> AsyncIterator[StreamItemResult]:
for i, item_id in enumerate(request.item_ids):
if i >= 2:
raise ConnectionError("Stream connection lost")
yield StreamItemResult(
item_id=item_id, status="processed", source="primary"
)
class FallbackStreamItemsHandler(
cqrs.StreamingRequestHandler[StreamItemsCommand, StreamItemResult],
):
# ... same events protocol ...
async def handle(
self, request: StreamItemsCommand
) -> AsyncIterator[StreamItemResult]:
for item_id in request.item_ids:
yield StreamItemResult(
item_id=item_id, status="from_fallback", source="fallback"
)
# Mapper
mapper.bind(
StreamItemsCommand,
cqrs.RequestHandlerFallback(
primary=PrimaryStreamItemsHandler,
fallback=FallbackStreamItemsHandler,
failure_exceptions=(ConnectionError, TimeoutError),
),
)
# Usage: bootstrap_streaming(...), then exhaust or aclose:
from contextlib import aclosing
async with aclosing(
mediator.stream(StreamItemsCommand(item_ids=["a", "b", "c", "d"]))
) as stream:
async for response in stream:
if response is not None:
print(response.item_id, response.source) # primary, primary, fallback, fallback...
```
The client receives items from the primary stream until it raises, then items from the fallback stream. Items already yielded are **not** cancelled. Optional `failure_exceptions` and `circuit_breaker` behave as for non-streaming [Request Handler Fallback](https://mkdocs.python-cqrs.dev/latest/request_handler/fallback/index.md).
## Circuit Breaker configuration
Streaming fallback uses the same `RequestHandlerFallback` wrapper, so Circuit Breaker is configured the same way as for [Request Handler Fallback](https://mkdocs.python-cqrs.dev/latest/request_handler/fallback/#circuit-breaker-optional).
- **Adapter:** `AioBreakerAdapter` from `cqrs.adapters.circuit_breaker`.
- **Parameters:** `fail_max` (default `5`), `timeout_duration` (seconds, default `60`), `exclude` (exceptions that do not count as failures), optional `storage_factory` for Redis/distributed state.
- **One instance per domain** — e.g. one adapter for all streaming fallbacks that share the same policy; the adapter creates an isolated circuit per handler type.
Example:
```python
from cqrs.adapters.circuit_breaker import AioBreakerAdapter
stream_cb = AioBreakerAdapter(fail_max=3, timeout_duration=30)
mapper.bind(
StreamItemsCommand,
cqrs.RequestHandlerFallback(
primary=PrimaryStreamItemsHandler,
fallback=FallbackStreamItemsHandler,
failure_exceptions=(ConnectionError, TimeoutError),
circuit_breaker=stream_cb,
),
)
```
Full configuration options (exclude, storage_factory, failure_exceptions) are described in [Request Handler Fallback — Circuit Breaker configuration](https://mkdocs.python-cqrs.dev/latest/request_handler/fallback/#circuit-breaker-configuration) and [Saga Fallback — Circuit Breaker](https://mkdocs.python-cqrs.dev/latest/saga/fallback/circuit_breaker/index.md).
## Related
- [Request Handler Fallback](https://mkdocs.python-cqrs.dev/latest/request_handler/fallback/index.md) — Same wrapper for non-streaming handlers
- [Stream Handling Configuration](https://mkdocs.python-cqrs.dev/latest/stream_handling/configuration/index.md) — Bootstrap and mediator setup
- [Saga Fallback Pattern](https://mkdocs.python-cqrs.dev/latest/saga/fallback/index.md) — Fallback for saga steps
# FastAPI Integration
- **Back to Stream Handling Overview**
Return to the Stream Handling overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/stream_handling/index.md)
______________________________________________________________________
## Overview
`StreamingRequestMediator` is designed for use with Server-Sent Events (SSE) in FastAPI applications:
```python
import fastapi
import json
from cqrs.bootstrap import requests as bootstrap
def streaming_mediator_factory() -> cqrs.StreamingRequestMediator:
return bootstrap.bootstrap_streaming(
di_container=container,
commands_mapper=commands_mapper,
domain_events_mapper=domain_events_mapper,
message_broker=broker,
max_concurrent_event_handlers=3,
concurrent_event_handle_enable=True,
)
@app.post("/process-files")
async def process_files_stream(
command: ProcessFilesCommand,
mediator: cqrs.StreamingRequestMediator = fastapi.Depends(
streaming_mediator_factory
),
) -> fastapi.responses.StreamingResponse:
async def generate_sse():
yield f"data: {json.dumps({'type': 'start', 'message': 'Processing...'})}\n\n"
async for result in mediator.stream(command):
if result is None:
continue
sse_data = {
"type": "progress",
"data": result.model_dump(),
}
yield f"data: {json.dumps(sse_data)}\n\n"
yield f"data: {json.dumps({'type': 'complete'})}\n\n"
return fastapi.responses.StreamingResponse(
generate_sse(),
media_type="text/event-stream",
)
```
Here's a complete example demonstrating streaming handlers with parallel event processing:
```python
import asyncio
import typing
from datetime import datetime
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
from cqrs.message_brokers import devnull
# Domain models
class ProcessOrdersCommand(cqrs.Request):
order_ids: list[str]
class OrderProcessedResult(cqrs.Response):
order_id: str
status: str
processed_at: datetime
items_count: int
# Domain events
class OrderProcessedEvent(cqrs.DomainEvent, frozen=True):
order_id: str
customer_id: str
total_amount: float
class OrderAnalyticsEvent(cqrs.DomainEvent, frozen=True):
order_id: str
category: str
# Streaming handler
class ProcessOrdersCommandHandler(
cqrs.StreamingRequestHandler[ProcessOrdersCommand, OrderProcessedResult]
):
def __init__(self):
self._events: list[cqrs.Event] = []
@property
def events(self) -> list[cqrs.Event]:
return self._events.copy()
def clear_events(self) -> None:
self._events.clear()
async def handle(
self, request: ProcessOrdersCommand
) -> typing.AsyncIterator[OrderProcessedResult]:
for order_id in request.order_ids:
# Simulate processing
await asyncio.sleep(0.1)
# Create result
result = OrderProcessedResult(
order_id=order_id,
status="processed",
processed_at=datetime.now(),
items_count=3,
)
# Emit multiple events
self._events.append(
OrderProcessedEvent(
order_id=order_id,
customer_id=f"customer_{order_id}",
total_amount=100.0,
)
)
self._events.append(
OrderAnalyticsEvent(
order_id=order_id,
category="electronics",
)
)
yield result
# Event handlers
class OrderProcessedEventHandler(cqrs.EventHandler[OrderProcessedEvent]):
async def handle(self, event: OrderProcessedEvent) -> None:
print(f"Order {event.order_id} processed for customer {event.customer_id}")
class OrderAnalyticsEventHandler(cqrs.EventHandler[OrderAnalyticsEvent]):
async def handle(self, event: OrderAnalyticsEvent) -> None:
print(f"Analytics updated for order {event.order_id} in category {event.category}")
# Mappers
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(ProcessOrdersCommand, ProcessOrdersCommandHandler)
def domain_events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(OrderProcessedEvent, OrderProcessedEventHandler)
mapper.bind(OrderAnalyticsEvent, OrderAnalyticsEventHandler)
# Bootstrap
async def main():
mediator = bootstrap.bootstrap_streaming(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=domain_events_mapper,
message_broker=devnull.DevnullMessageBroker(),
max_concurrent_event_handlers=3,
concurrent_event_handle_enable=True,
)
command = ProcessOrdersCommand(order_ids=["order1", "order2", "order3"])
async for result in mediator.stream(command):
print(f"Processed: {result.order_id}")
if __name__ == "__main__":
asyncio.run(main())
```
# Reference
- **Back to Stream Handling Overview**
Return to the Stream Handling overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/stream_handling/index.md)
______________________________________________________________________
### Incremental Processing
Streaming handlers process items one at a time, yielding results as they become available. This allows clients to receive progress updates in real-time.
### Event Emission
After each yield, events are collected and can be emitted. Events are processed after each yield, allowing for parallel processing of side effects.
### Parallel Event Processing
Events can be processed concurrently with configurable concurrency limits. This improves performance when multiple event handlers need to process events independently.
### SSE Integration
Streaming handlers work seamlessly with Server-Sent Events (SSE) in FastAPI, enabling real-time progress updates in web applications.
Streaming handlers are ideal for:
- **Batch processing** — Processing large batches of items with progress updates
- **File uploads** — Processing uploaded files one by one
- **Data import** — Importing data with real-time progress
- **Long-running operations** — Operations that take time and benefit from progress updates
- **Real-time updates** — Applications that need to show progress to users
- **Clear events after processing** — Implement `clear_events()` to prevent event accumulation
- **Use appropriate concurrency limits** — Set `max_concurrent_event_handlers` based on your resource constraints
- **Handle errors gracefully** — Wrap streaming logic in try-except blocks
- **Yield meaningful results** — Include progress information in response objects
- **Use SSE for web applications** — Stream results via SSE for better user experience
| Feature | Regular Handler | Streaming Handler |
| ---------------- | ----------------- | ----------------------------- |
| Response | Single response | Multiple responses (yielded) |
| Processing | All at once | Incremental |
| Progress Updates | Not available | Real-time |
| Event Processing | After completion | After each yield |
| Use Case | Simple operations | Batch/long-running operations |
### API Contract and Typing
**StreamingRequestHandler**
The base class declares `handle` as a **sync** method returning an iterator: `def handle(self, request: ReqT) -> AsyncIterator[ResT]`. Subclasses implement it as an **async generator** (`async def handle(...): ... yield ...`). This keeps types compatible with Pyright and mypy — **no type ignores** are needed on your handler's `handle` method.
**Stream and consumption**
- `mediator.stream(request)` is called **without** `await`.
- It returns an `AsyncIterator`; consume it with `async for`:
```python
# Correct: no await, iterate with async for
async for result in mediator.stream(command):
print(result)
```
# Chain of Responsibility
The Chain of Responsibility pattern allows multiple handlers to process a request in sequence until one successfully handles it. This pattern is particularly useful when you have multiple processing strategies or need to implement fallback mechanisms.
## Overview
- **Examples**
Registering handlers and complete examples of Chain of Responsibility pattern.
[Read More](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/examples/index.md)
- **Advanced Topics**
Manual chain building, handler methods, and integration patterns.
[Read More](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/advanced/index.md)
- **Mermaid Diagrams**
Generate Sequence and Class diagrams for documentation.
[Read More](https://mkdocs.python-cqrs.dev/latest/mermaid/chain_of_responsibility/index.md)
- **Fallback**
Combine CoR with Request Handler Fallback when the chain raises.
[Read More](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/fallback/index.md)
`CORRequestHandler` implements the Chain of Responsibility pattern, allowing multiple handlers to process a request sequentially. Each handler decides whether to process the request or pass it to the next handler in the chain. The chain stops when a handler successfully processes the request or when all handlers have been exhausted.
### Key Benefits
- **Flexible processing** — Multiple strategies can be tried in order
- **Fallback mechanisms** — Default handlers can handle unsupported cases
- **Separation of concerns** — Each handler focuses on a specific case
- **Easy to extend** — Add new handlers without modifying existing ones
Prerequisites
Understanding of [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) and [Bootstrap](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md) is recommended.
When to Use
Use Chain of Responsibility when you have multiple processing strategies or need fallback mechanisms. For standard request handling, use regular [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md).
## Pattern Description
The Chain of Responsibility pattern consists of:
1. **Handler Interface** — `CORRequestHandler` defines the interface for handlers in the chain
1. **Concrete Handlers** — Multiple handlers that can process requests
1. **Chain Building** — Handlers are linked together in a specific order
1. **Request Passing** — Each handler can either process the request or pass it to the next handler
### How It Works
```text
Request → Handler 1 → Handler 2 → Handler 3 → Default Handler
↓ ↓ ↓ ↓
Process? Process? Process? Always Process
↓ ↓ ↓ ↓
Yes/No Yes/No Yes/No Yes
↓ ↓ ↓ ↓
Return Return Return Return
```
Each handler in the chain:
1. Receives the request
1. Decides if it can handle it
1. If yes: processes and returns result
1. If no: passes to next handler using `await self.next(request)`
## Basic Example
Here's a simple example demonstrating the Chain of Responsibility pattern:
```python
import typing
import cqrs
from cqrs.handlers.cor import CORRequestHandler
class ProcessPaymentCommand(cqrs.Request):
amount: float
payment_method: str
user_id: str
class PaymentResult(cqrs.Response):
success: bool
transaction_id: str | None = None
message: str = ""
class PaymentProcessedEvent(cqrs.Event):
transaction_id: str
amount: float
user_id: str
class CreditCardHandler(CORRequestHandler[ProcessPaymentCommand, PaymentResult]):
def __init__(self):
self._events: list[cqrs.Event] = []
@property
def events(self) -> list[cqrs.Event]:
return self._events
async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
if request.payment_method == "credit_card":
transaction_id = f"cc_{request.user_id}_{int(request.amount * 100)}"
self._events.append(
PaymentProcessedEvent(
transaction_id=transaction_id,
amount=request.amount,
user_id=request.user_id,
)
)
return PaymentResult(
success=True,
transaction_id=transaction_id,
message="Payment processed via credit card",
)
# Pass to next handler if can't handle
return await self.next(request)
class PayPalHandler(CORRequestHandler[ProcessPaymentCommand, PaymentResult]):
def __init__(self):
self._events: list[cqrs.Event] = []
@property
def events(self) -> list[cqrs.Event]:
return self._events
async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
if request.payment_method == "paypal":
transaction_id = f"pp_{request.user_id}_{int(request.amount * 100)}"
self._events.append(
PaymentProcessedEvent(
transaction_id=transaction_id,
amount=request.amount,
user_id=request.user_id,
)
)
return PaymentResult(
success=True,
transaction_id=transaction_id,
message="Payment processed via PayPal",
)
# Pass to next handler if can't handle
return await self.next(request)
class DefaultPaymentHandler(CORRequestHandler[ProcessPaymentCommand, PaymentResult]):
@property
def events(self) -> list[cqrs.Event]:
return []
async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
# Default handler always handles the request
return PaymentResult(
success=False,
message=f"Unsupported payment method: {request.payment_method}",
)
```
# Advanced Topics
- **Back to Chain of Responsibility Overview**
Return to the Chain of Responsibility overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/index.md)
______________________________________________________________________
## Overview
You can also build chains manually using the `build_chain()` function:
```python
from cqrs.handlers.cor import build_chain
# Create handler instances
handler1 = CreditCardHandler()
handler2 = PayPalHandler()
handler3 = DefaultPaymentHandler()
# Build chain manually
chain = build_chain([handler1, handler2, handler3])
# Use the chain
result = await chain.handle(
ProcessPaymentCommand(
amount=100.0,
payment_method="credit_card",
user_id="user1",
)
)
```
### `set_next(handler)`
Sets the next handler in the chain. Returns the next handler to allow chaining:
```python
handler1.set_next(handler2).set_next(handler3)
```
### `next(request)`
Passes the request to the next handler in the chain:
```python
async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
if can_handle(request):
return process(request)
# Pass to next handler
return await self.next(request)
```
### `events` Property
Returns the list of events generated by the handler. Must be implemented:
```python
@property
def events(self) -> list[cqrs.Event]:
return self._events.copy()
```
Chain of Responsibility is ideal for:
- **Payment processing** — Try different payment methods in order
- **Authentication** — Try multiple authentication strategies
- **Validation** — Multiple validation rules with fallback
- **Error handling** — Try different recovery strategies
- **Feature flags** — Try different implementations based on availability
- **Rate limiting** — Multiple rate limiters with fallback
- **Order matters** — Place handlers in order of preference or priority
- **Always have a default** — Include a default handler at the end of the chain
- **Clear decision logic** — Make it obvious when a handler can process a request
- **Return early** — Return immediately after processing, don't continue the chain
- **Use `next()` correctly** — Only call `next()` when you can't handle the request
- **Handle events** — Collect events from handlers that process requests
| Feature | Regular Handler | COR Handler |
| ------------ | ----------------- | ----------------------------- |
| Processing | Single handler | Multiple handlers in sequence |
| Decision | Always processes | Decides whether to process |
| Fallback | Not available | Built-in via chain |
| Use Case | Simple operations | Multiple strategies |
| Registration | Single handler | List of handlers |
Chain of Responsibility works well with:
- **Dependency Injection** — Handlers can be injected with dependencies
- **Event Handling** — Handlers can emit events when processing requests
- **Middleware** — Middleware can be applied to the entire chain
- **Regular Handlers** — COR handlers can be used alongside regular handlers
The Chain of Responsibility pattern provides a flexible way to handle requests with multiple processing strategies. By linking handlers together, you can:
- Try multiple strategies in order
- Implement fallback mechanisms
- Separate concerns across handlers
- Easily extend functionality by adding new handlers
# Examples
- **Back to Chain of Responsibility Overview**
Return to the Chain of Responsibility overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/index.md)
______________________________________________________________________
## Overview
To register a chain of handlers, bind the request type to a list of handler classes:
```python
import cqrs
from cqrs.bootstrap import requests as bootstrap
def payment_mapper(mapper: cqrs.RequestMap) -> None:
"""Register the chain of payment handlers."""
mapper.bind(
ProcessPaymentCommand,
[
CreditCardHandler, # First in chain
PayPalHandler, # Second in chain
DefaultPaymentHandler, # Last in chain (fallback)
],
)
# Bootstrap mediator
mediator = bootstrap.bootstrap(
di_container=container,
commands_mapper=payment_mapper,
)
# Use the mediator
result = await mediator.send(
ProcessPaymentCommand(
amount=100.0,
payment_method="credit_card",
user_id="user1",
)
)
```
The framework automatically:
1. Resolves all handlers from the DI container
1. Builds the chain using `build_chain()`
1. Links handlers in the order specified
1. Uses the first handler as the entry point
Here's a complete example demonstrating payment processing with multiple handlers:
```python
import asyncio
import typing
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
from cqrs.handlers.cor import CORRequestHandler
# Domain models
class ProcessPaymentCommand(cqrs.Request):
amount: float
payment_method: str
user_id: str
class PaymentResult(cqrs.Response):
success: bool
transaction_id: str | None = None
message: str = ""
class PaymentProcessedEvent(cqrs.Event):
transaction_id: str
amount: float
user_id: str
# Chain handlers
class CreditCardHandler(CORRequestHandler[ProcessPaymentCommand, PaymentResult]):
def __init__(self):
self._events: list[cqrs.Event] = []
@property
def events(self) -> list[cqrs.Event]:
return self._events
async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
if request.payment_method == "credit_card":
transaction_id = f"cc_{request.user_id}_{int(request.amount * 100)}"
self._events.append(
PaymentProcessedEvent(
transaction_id=transaction_id,
amount=request.amount,
user_id=request.user_id,
)
)
return PaymentResult(
success=True,
transaction_id=transaction_id,
message="Payment processed via credit card",
)
return await self.next(request)
class PayPalHandler(CORRequestHandler[ProcessPaymentCommand, PaymentResult]):
def __init__(self):
self._events: list[cqrs.Event] = []
@property
def events(self) -> list[cqrs.Event]:
return self._events
async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
if request.payment_method == "paypal":
transaction_id = f"pp_{request.user_id}_{int(request.amount * 100)}"
self._events.append(
PaymentProcessedEvent(
transaction_id=transaction_id,
amount=request.amount,
user_id=request.user_id,
)
)
return PaymentResult(
success=True,
transaction_id=transaction_id,
message="Payment processed via PayPal",
)
return await self.next(request)
class BankTransferHandler(CORRequestHandler[ProcessPaymentCommand, PaymentResult]):
def __init__(self):
self._events: list[cqrs.Event] = []
@property
def events(self) -> list[cqrs.Event]:
return self._events
async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
if request.payment_method == "bank_transfer":
transaction_id = f"bt_{request.user_id}_{int(request.amount * 100)}"
self._events.append(
PaymentProcessedEvent(
transaction_id=transaction_id,
amount=request.amount,
user_id=request.user_id,
)
)
return PaymentResult(
success=True,
transaction_id=transaction_id,
message="Payment processed via bank transfer",
)
return await self.next(request)
class DefaultPaymentHandler(CORRequestHandler[ProcessPaymentCommand, PaymentResult]):
@property
def events(self) -> list[cqrs.Event]:
return []
async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
return PaymentResult(
success=False,
message=f"Unsupported payment method: {request.payment_method}",
)
# Mapper
def payment_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(
ProcessPaymentCommand,
[
CreditCardHandler,
PayPalHandler,
BankTransferHandler,
DefaultPaymentHandler,
],
)
# Usage
async def main():
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=payment_mapper,
)
# Test different payment methods
result1 = await mediator.send(
ProcessPaymentCommand(
amount=100.0,
payment_method="credit_card",
user_id="user1",
)
)
print(f"Credit card: {result1.message}")
result2 = await mediator.send(
ProcessPaymentCommand(
amount=50.0,
payment_method="paypal",
user_id="user2",
)
)
print(f"PayPal: {result2.message}")
result3 = await mediator.send(
ProcessPaymentCommand(
amount=75.0,
payment_method="crypto", # Unsupported
user_id="user3",
)
)
print(f"Unsupported: {result3.message}")
if __name__ == "__main__":
asyncio.run(main())
```
# Chain of Responsibility Fallback
- **Back to Chain of Responsibility Overview**
Return to the Chain of Responsibility overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/index.md)
______________________________________________________________________
## Overview
You can combine **Chain of Responsibility** with **Request Handler Fallback**: the primary handler is a `RequestHandler` that delegates to a COR chain; when the chain raises (e.g. downstream failure), the fallback handler is invoked. This is useful when a request is first tried through a chain (e.g. cache → DB → external API) and you want a default/cached response if the whole chain fails.
| Concept | Description |
| ------------ | ---------------------------------------------------------------------------------------------------- |
| **Primary** | A `RequestHandler` that runs the COR chain (e.g. injects chain entry via DI) |
| **Fallback** | A `RequestHandler` used when the chain raises |
| **Flow** | `mediator.send(request)` dispatches to primary; primary calls the chain; on exception, fallback runs |
When to Use
Use COR + Fallback when you have a chain of strategies (CoR) and a clear fallback path if the entire chain fails (e.g. connection errors to the last handler).
## Registration
Bind the request type to `RequestHandlerFallback` with a **wrapper** handler as primary (the one that runs the chain) and a simple handler as fallback:
```python
import cqrs
from cqrs.handlers.cor import CORRequestHandler, build_chain
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(
FetchDataCommand,
cqrs.RequestHandlerFallback(
primary=CORChainWrapperHandler, # RequestHandler that delegates to chain
fallback=FallbackFetchDataHandler,
failure_exceptions=(ConnectionError, TimeoutError),
),
)
```
Build the chain and inject the chain entry (first handler) so the wrapper receives it:
```python
source_a = SourceAHandler()
source_b = SourceBHandler()
default = DefaultChainHandler()
build_chain([source_a, source_b, default])
di_container.bind(
di.bind_by_type(
dependent.Dependent(lambda: source_a, scope="request"),
SourceAHandler,
),
)
mediator = bootstrap.bootstrap(
di_container=di_container,
commands_mapper=commands_mapper,
)
```
## Wrapper Handler
The primary handler is a normal `RequestHandler` that holds the chain entry and delegates:
```python
class CORChainWrapperHandler(
cqrs.RequestHandler[FetchDataCommand, FetchDataResult],
):
"""Primary handler: runs the COR chain; chain entry injected via DI."""
def __init__(self, chain_entry: SourceAHandler) -> None:
self._chain_entry = chain_entry
@property
def events(self) -> list[cqrs.Event]:
return []
async def handle(self, request: FetchDataCommand) -> FetchDataResult:
result = await self._chain_entry.handle(request)
if result is None:
raise ValueError("COR chain did not handle the request")
return result
```
When any handler in the chain raises (e.g. `ConnectionError`), the dispatcher catches it and invokes the fallback handler. Use `failure_exceptions` to restrict fallback to specific exception types.
## Fallback Handler
The fallback is a simple `RequestHandler` that returns a default or cached response:
```python
class FallbackFetchDataHandler(
cqrs.RequestHandler[FetchDataCommand, FetchDataResult],
):
@property
def events(self) -> list[cqrs.Event]:
return []
async def handle(self, request: FetchDataCommand) -> FetchDataResult:
return FetchDataResult(
data="cached_or_default",
source="fallback",
)
```
## Circuit Breaker configuration
CoR + Fallback uses `RequestHandlerFallback`, so Circuit Breaker is configured the same way as for [Request Handler Fallback](https://mkdocs.python-cqrs.dev/latest/request_handler/fallback/#circuit-breaker-optional).
- **Adapter:** `AioBreakerAdapter` from `cqrs.adapters.circuit_breaker`.
- **Parameters:** `fail_max` (default `5`), `timeout_duration` (seconds, default `60`), `exclude` (exceptions that do not open the circuit), optional `storage_factory` for distributed state.
- **One instance per domain** — e.g. one adapter for request fallbacks (including COR wrapper); the adapter creates an isolated circuit per handler type.
Example:
```python
from cqrs.adapters.circuit_breaker import AioBreakerAdapter
request_cb = AioBreakerAdapter(fail_max=5, timeout_duration=60)
mapper.bind(
FetchDataCommand,
cqrs.RequestHandlerFallback(
primary=CORChainWrapperHandler,
fallback=FallbackFetchDataHandler,
failure_exceptions=(ConnectionError, TimeoutError),
circuit_breaker=request_cb,
),
)
```
Full configuration (exclude, storage_factory, failure_exceptions) is described in [Request Handler Fallback — Circuit Breaker configuration](https://mkdocs.python-cqrs.dev/latest/request_handler/fallback/#circuit-breaker-configuration) and [Saga Fallback — Circuit Breaker](https://mkdocs.python-cqrs.dev/latest/saga/fallback/circuit_breaker/index.md).
## Related
- [Request Handler Fallback](https://mkdocs.python-cqrs.dev/latest/request_handler/fallback/index.md) — Fallback for command/query handlers
- [Chain of Responsibility Examples](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/examples/index.md) — Registering and building COR chains
- [Saga Fallback Pattern](https://mkdocs.python-cqrs.dev/latest/saga/fallback/index.md) — Fallback for saga steps
# Saga
# Saga Pattern
The Saga pattern enables distributed transactions across multiple services by executing a series of steps where each step can be compensated if a subsequent step fails. This allows for eventual consistency across distributed systems without requiring two-phase commit.
## Overview
- **Flow Diagrams**
Visual representation of execution and compensation flows.
[Read More](https://mkdocs.python-cqrs.dev/latest/saga/flow/index.md)
- **Storage**
Memory, SQLAlchemy, and SQLModel storage for saga state persistence.
[Read More](https://mkdocs.python-cqrs.dev/latest/saga/storage/index.md)
- **Recovery**
How recovery ensures eventual consistency for interrupted sagas.
[Read More](https://mkdocs.python-cqrs.dev/latest/saga/recovery/index.md)
- **Compensation**
Compensation mechanism and best practices for rollback operations.
[Read More](https://mkdocs.python-cqrs.dev/latest/saga/compensation/index.md)
- **Fallback Pattern**
Fallback steps with Circuit Breaker protection for resilient sagas.
[Read More](https://mkdocs.python-cqrs.dev/latest/saga/fallback/index.md)
- **Examples**
Complete examples including FastAPI SSE integration.
[Read More](https://mkdocs.python-cqrs.dev/latest/saga/examples/index.md)
The `python-cqrs` package implements the **Orchestrated Saga** pattern. `SagaTransaction` manages step execution sequentially and handles automatic compensation on failure.
Orchestrated Saga
`SagaTransaction` coordinates step execution, but each step handler is responsible for its own logic. The transaction manages flow, state persistence, and automatic compensation.
### Key Concepts
| Concept | Description |
| ------------------- | ------------------------------------------------------------------------- |
| **SagaTransaction** | Context manager that executes steps sequentially and handles compensation |
| **Saga** | Class that defines steps and creates `SagaTransaction` instances |
| **Step Handler** | Operation with `act()` and `compensate()` methods |
| **Context** | Shared state object passed between all steps |
| **Storage** | Persists saga state and execution history for recovery |
| **Compensation** | Automatic rollback of completed steps in reverse order on failure |
| **Recovery** | Mechanism to resume interrupted sagas from persistent storage |
Prerequisites
Understanding of [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) and [Dependency Injection](https://mkdocs.python-cqrs.dev/latest/di/index.md) is recommended.
When to Use
Use Saga pattern when coordinating multiple operations across different services, where each operation can be compensated if the overall transaction fails.
## How It Works
`SagaTransaction` executes steps sequentially and automatically compensates completed steps in reverse order if any step fails:
```
graph TD
A[Create SagaTransaction] --> B[Execute Step 1]
B -->|Success| C[Execute Step 2]
C -->|Success| D[Execute Step 3]
D -->|Success| E[Saga Completed]
B -->|Failure| F[Compensate Step 1]
C -->|Failure| G[Compensate Step 2]
G --> F
D -->|Failure| H[Compensate Step 3]
H --> G
F --> I[Saga Failed]
G --> I
H --> I
style E fill:#c8e6c9
style I fill:#ffcdd2
```
## Basic Example
```python
import dataclasses
import uuid
import di
import cqrs
from cqrs.bootstrap import saga as bootstrap
from cqrs.saga.saga import Saga
from cqrs.handlers.saga import SagaStepHandler, SagaStepResult
from cqrs.saga.storage.memory import MemorySagaStorage
from cqrs.saga.models import SagaContext
from cqrs.models.response import Response
# Context
@dataclasses.dataclass
class OrderContext(SagaContext):
order_id: str
items: list[str]
total_amount: float
inventory_reservation_id: str | None = None
payment_id: str | None = None
# Step handler
class ReserveInventoryStep(SagaStepHandler[OrderContext, Response]):
def __init__(self, inventory_service):
self._inventory_service = inventory_service
async def act(self, context: OrderContext) -> SagaStepResult:
reservation_id = await self._inventory_service.reserve_items(
context.order_id, context.items
)
context.inventory_reservation_id = reservation_id
return self._generate_step_result(Response())
async def compensate(self, context: OrderContext) -> None:
if context.inventory_reservation_id:
await self._inventory_service.release_items(
context.inventory_reservation_id
)
# Define saga class with steps
class OrderSaga(Saga[OrderContext]):
steps = [ReserveInventoryStep]
# Setup DI container
di_container = di.Container()
# ... register your services ...
# Create saga storage
storage = MemorySagaStorage()
# Register saga in SagaMap
def saga_mapper(mapper: cqrs.SagaMap) -> None:
mapper.bind(OrderContext, OrderSaga)
# Create saga mediator using bootstrap
mediator = bootstrap.bootstrap(
di_container=di_container,
sagas_mapper=saga_mapper,
saga_storage=storage,
)
# Execute saga
context = OrderContext(order_id="123", items=["item_1"], total_amount=100.0)
saga_id = uuid.uuid4()
async for step_result in mediator.execute(context, saga_id=saga_id):
print(f"Step completed: {step_result.step_type.__name__}")
```
Stream API
`mediator.execute(context, saga_id=...)` is called **without** `await` and returns an `AsyncIterator[SagaStepResult]`. Consume it with `async for` as shown above.
## Key Features
- **Automatic Compensation** — Failed steps trigger compensation of completed steps in reverse order
- **State Persistence** — Saga state and execution history saved (when storage supports **checkpoint commits**, persistence happens at key points: after create+RUNNING, after each step, after each compensation step, at completion/failure)
- **Checkpoint Commits** — Storages that implement **`create_run()`** (Memory, SQLAlchemy) use one session per saga run and commit only at checkpoints, reducing commits and deadlock risk
- **Recovery** — Interrupted sagas can be resumed from persistent storage; **Strict Backward Recovery** ensures that once in COMPENSATING/FAILED, only compensation proceeds
- **Fallback Pattern** — Define alternative steps that execute when primary steps fail, with optional Circuit Breaker protection
- **Eventual Consistency** — All sagas eventually reach terminal state (COMPLETED or FAILED)
# Saga Flow Diagrams
- **Back to Saga Overview**
Return to the Saga Pattern overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/saga/index.md)
Visual representations of saga execution, compensation, and recovery flows.
## Execution Flow
### Successful Saga Execution
When the storage supports **`create_run()`**, the saga runs inside one session and **commits only at checkpoints** (after create + RUNNING, after each step, at COMPLETED). Otherwise, each storage call may commit immediately (legacy behaviour).
```
sequenceDiagram
participant Client
participant Transaction as SagaTransaction
participant Run as SagaStorageRun
participant Steps as Steps
Client->>Transaction: Execute saga(context, saga_id)
Transaction->>Run: create_saga() + update_status(RUNNING)
Note over Transaction,Run: checkpoint: commit()
loop For each step
Transaction->>Steps: act(context)
Steps-->>Transaction: Success
Transaction->>Run: log_step(COMPLETED) + update_context()
Note over Transaction,Run: checkpoint: commit()
end
Transaction->>Run: update_status(COMPLETED)
Note over Transaction,Run: checkpoint: commit()
Transaction-->>Client: Success
```
### Failed Saga with Compensation
When using a **run** (checkpoint path), a **commit** occurs after each completed step and after each compensated step; then once at the end when status is set to FAILED.
```
sequenceDiagram
participant Client
participant Transaction as SagaTransaction
participant Run as SagaStorageRun
participant Step1 as Step 1
participant Step2 as Step 2
participant Step3 as Step 3
Client->>Transaction: Execute saga(context, saga_id)
Transaction->>Run: create_saga() + update_status(RUNNING)
Note over Transaction,Run: checkpoint: commit()
Transaction->>Step1: act(context)
Step1-->>Transaction: Success
Transaction->>Run: log_step(Step1.act, COMPLETED)
Note over Transaction,Run: checkpoint: commit()
Transaction->>Step2: act(context)
Step2-->>Transaction: Success
Transaction->>Run: log_step(Step2.act, COMPLETED)
Note over Transaction,Run: checkpoint: commit()
Transaction->>Step3: act(context)
Step3-->>Transaction: Exception
Transaction->>Run: log_step(Step3.act, FAILED) + update_status(COMPENSATING)
Note over Transaction,Run: checkpoint: commit()
Note over Transaction,Step2: Compensation in reverse order
Transaction->>Step2: compensate(context)
Step2-->>Transaction: Done
Transaction->>Run: log_step(Step2.compensate, COMPLETED)
Note over Transaction,Run: checkpoint: commit()
Transaction->>Step1: compensate(context)
Step1-->>Transaction: Done
Transaction->>Run: log_step(Step1.compensate, COMPLETED)
Note over Transaction,Run: checkpoint: commit()
Transaction->>Run: update_status(FAILED)
Note over Transaction,Run: checkpoint: commit()
Transaction-->>Client: Exception raised
```
## State Transitions
### Saga Status Flow
```
stateDiagram-v2
[*] --> PENDING: Create saga
PENDING --> RUNNING: Start execution
RUNNING --> COMPLETED: All steps succeed ✓
RUNNING --> COMPENSATING: Step fails ✗
COMPENSATING --> FAILED: Compensation done
COMPLETED --> [*]
FAILED --> [*]
note right of COMPENSATING
Point of No Return:
Forward execution disabled
end note
```
## Recovery Flow
### Recovery Process
```
sequenceDiagram
participant Recovery as Recovery Process
participant Storage as SagaStorage
participant Transaction as SagaTransaction
participant Steps as Remaining Steps
Note over Recovery: Saga was interrupted
Recovery->>Storage: load_saga_state(saga_id)
Storage-->>Recovery: Status + Context + History
Recovery->>Transaction: recover_saga(saga, saga_id)
alt Status: RUNNING
Note over Transaction: Resume forward execution
Transaction->>Steps: Continue from last completed step
else Status: COMPENSATING
Note over Transaction: Resume compensation
Transaction->>Steps: Continue compensation in reverse
end
Transaction->>Storage: Update status (COMPLETED/FAILED)
```
## Storage Structure
### Tables
**saga_executions:**
- `id` (UUID) - Primary key
- `status` (Enum) - PENDING, RUNNING, COMPENSATING, COMPLETED, FAILED
- `context` (JSON) - Serialized context data
- `created_at`, `updated_at` (DateTime)
**saga_logs:**
- `id` (BigInteger) - Primary key
- `saga_id` (UUID) - Foreign key
- `step_name` (String)
- `action` (String) - "act" or "compensate"
- `status` (Enum) - STARTED, COMPLETED, FAILED
- `details` (Text) - Error message if failed
- `created_at` (DateTime)
# Saga Storage
- **Back to Saga Overview**
Return to the Saga Pattern overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/saga/index.md)
Storage persists saga state and execution history, enabling recovery of interrupted sagas and ensuring eventual consistency.
## Overview
Two storage implementations:
| Storage Type | Use Case | Persistence |
| ------------------------- | ------------------------- | -------------------------------------------------- |
| **MemorySagaStorage** | Testing, development | In-memory (not persistent) |
| **SqlAlchemySagaStorage** | Production | Database (persistent) |
| **SqlModelSagaStorage** | Production, SQLModel apps | Database (persistent, same behavior as SQLAlchemy) |
## Storage Interface
```python
class ISagaStorage(abc.ABC):
async def create_saga(saga_id, name, context) -> None
async def update_context(saga_id, context, current_version: int | None = None) -> None
async def update_status(saga_id, status) -> None
async def log_step(saga_id, step_name, action, status, details=None) -> None
async def load_saga_state(saga_id, *, read_for_update: bool = False) -> tuple[SagaStatus, dict, int]
async def get_step_history(saga_id) -> list[SagaLogEntry]
async def get_sagas_for_recovery(limit, max_recovery_attempts=5, stale_after_seconds=None, saga_name=None) -> list[uuid.UUID]
async def increment_recovery_attempts(saga_id, new_status: SagaStatus | None = None) -> None
async def set_recovery_attempts(saga_id, attempts: int) -> None
# Optional: checkpoint commits (reduces storage load and deadlock risk)
def create_run(self) -> contextlib.AbstractAsyncContextManager[SagaStorageRun]:
"""Yield a SagaStorageRun for one saga execution; raises NotImplementedError if not supported."""
raise NotImplementedError("This storage does not support create_run()")
```
- **get_sagas_for_recovery** — Returns saga IDs that need recovery (RUNNING, COMPENSATING) with `recovery_attempts` < `max_recovery_attempts`, optionally filtered by staleness and by saga name. When `saga_name` is `None` (default), returns all saga types; when set, only sagas with that name. Used by recovery jobs.
- **increment_recovery_attempts** — Called automatically by `recover_saga()` on recovery failure; increments `recovery_attempts` and optionally updates status (e.g. to FAILED).
- **set_recovery_attempts** — Sets the recovery attempt counter to an explicit value. Use to reset after successfully recovering a step (e.g. set to `0`) or to set to the maximum so the saga is excluded from further recovery (e.g. mark as permanently failed without changing status).
### Checkpoint commits and `SagaStorageRun`
When a storage implements **`create_run()`**, the saga can run in a single session with **checkpoint commits**: one commit only at key points (after creating the saga and setting RUNNING, after each completed step, after each compensated step, at completion or failure) instead of committing after every storage call. This reduces the number of commits, shortens lock hold time, and lowers deadlock risk (e.g. with SQLAlchemy).
- **`SagaStorageRun`** — Protocol for a scoped “session” for a single saga. It exposes the same mutation/read methods as the storage (`create_saga`, `update_context`, `update_status`, `log_step`, `load_saga_state`, `get_step_history`) but **does not commit** automatically; the caller must call **`commit()`** at the desired checkpoints. **`rollback()`** aborts the run without persisting changes.
- **`create_run()`** — Returns an async context manager that yields a `SagaStorageRun`. If a storage does not support it (e.g. a custom implementation), it may raise `NotImplementedError`; in that case `SagaTransaction` falls back to the previous behaviour (no single session, no checkpoint commits; each call may commit immediately depending on the implementation).
- **`load_saga_state(..., read_for_update=True)`** — When loading state for recovery or exclusive update, the implementation can lock the row (e.g. `SELECT ... FOR UPDATE` in SQLAlchemy). Together with checkpoint commits, this shortens lock duration and reduces deadlock risk.
## Memory Storage
In-memory implementation for testing and development.
```python
import cqrs
from cqrs.bootstrap import saga as bootstrap
from cqrs.saga.storage.memory import MemorySagaStorage
storage = MemorySagaStorage()
# Register saga in SagaMap
def saga_mapper(mapper: cqrs.SagaMap) -> None:
mapper.bind(OrderContext, OrderSaga)
# Create saga mediator using bootstrap
mediator = bootstrap.bootstrap(
di_container=di_container,
sagas_mapper=saga_mapper,
saga_storage=storage,
)
# Access storage data
status, context_data, version = await storage.load_saga_state(saga_id)
history = await storage.get_step_history(saga_id)
```
**Features:**
- ✅ Fast and lightweight
- ✅ No database setup required
- ✅ Implements **`create_run()`** — yields `_MemorySagaStorageRun`; `commit()` and `rollback()` are no-ops, but the protocol is aligned with SQLAlchemy for tests.
- ❌ Not persistent (data lost on restart)
## SQLAlchemy Storage
Database-backed implementation for production. It uses a session factory. When the saga uses **`create_run()`**, all operations for one saga run go through a single **`AsyncSession`** and are committed only at checkpoints (after create + RUNNING, after each step, after each compensation step, at completion/failure), which reduces commits and deadlock risk.
### Database Schema
**saga_executions:**
- `id` (UUID) - Primary key
- `status` (VARCHAR) - PENDING, RUNNING, COMPENSATING, COMPLETED, FAILED
- `context` (JSON) - Serialized saga context; declared with `JSONType` (see [Database Support](#database-support))
- `version` (INTEGER) - Optimistic locking version (default: 1)
- `recovery_attempts` (INTEGER) - Number of failed recovery attempts (default: 0); used by `get_sagas_for_recovery`, `increment_recovery_attempts`, and `set_recovery_attempts`
- `created_at`, `updated_at` (TIMESTAMP)
**saga_logs:**
- `id` (BIGSERIAL) - Primary key
- `saga_id` (UUID) - Foreign key
- `step_name` (VARCHAR)
- `action` (VARCHAR) - "act" or "compensate"
- `status` (VARCHAR) - STARTED, COMPLETED, FAILED
- `details` (TEXT)
- `created_at` (TIMESTAMP)
**Indexes:** `saga_id`, `created_at`
### Database Support
The saga storage schema is verified against **MySQL** and **PostgreSQL** — both are covered by integration tests — and works on either without any configuration. See [Outbox Database Support](https://mkdocs.python-cqrs.dev/latest/outbox/databases/index.md) for the full database matrix and the Alembic setup shared by all SQLAlchemy models in the package.
The `context` column is declared with `cqrs.sqlalchemy_types.JSONType`, a dialect-aware type that resolves to a plain `sqlalchemy.JSON()` on **every** dialect, PostgreSQL and MySQL included. The rendered DDL is therefore identical to a bare `sqlalchemy.JSON` column:
```sql
-- PostgreSQL
context JSON NOT NULL
-- MySQL
context JSON NOT NULL COMMENT 'Serialized context'
```
No migration needed
`JSONType` ships without a single per-dialect registration, so upgrading the package does not change the `saga_executions` DDL on any database. Existing deployments need no migration.
#### Optional: `JSONB` on PostgreSQL
The point of the type is the extension point it adds. If you want PostgreSQL's binary JSON, register it once:
```python
from sqlalchemy.dialects import postgresql
from cqrs.sqlalchemy_types import DialectTypeHandler, JSONType
JSONType.register_dialect(
"postgresql",
DialectTypeHandler(type_factory=lambda dialect: postgresql.JSONB()),
)
```
The `context` column then renders as `context JSONB NOT NULL` on PostgreSQL and stays `JSON` everywhere else.
Why it can be worth it:
| | `JSON` | `JSONB` |
| -------- | ---------------------------------- | -------------------------------------------- |
| Storage | Text, re-parsed on every access | Decomposed binary form |
| Indexing | No GIN indexes | GIN indexes, containment operators like `@>` |
| Writes | Cheaper | Slightly more expensive |
| Fidelity | Preserves key order and duplicates | Does not preserve key order |
So `JSONB` pays off if you intend to **query sagas by the content of `context`** — for example finding all sagas for a given order id. If `context` is only ever read back whole by the saga engine, plain `JSON` is fine and the default is the cheaper choice.
Existing PostgreSQL deployments need a manual migration
Registering the handler only changes what *new* DDL renders to. An existing `saga_executions` table keeps its `json` column until you convert it yourself:
```sql
ALTER TABLE saga_executions ALTER COLUMN context TYPE jsonb USING context::jsonb;
```
On a large table this rewrites every row while holding an `ACCESS EXCLUSIVE` lock, so schedule it like any other rewriting migration.
No `bind`/`result` converters for JSON
Serialization stays the responsibility of the dialect's JSON type, so the handler needs only `type_factory`. You still have to register **before the first use** — bind/result processors are memoized per dialect instance — and in both the application entry point and `alembic/env.py`; the reasoning is spelled out in [Register before the first use](https://mkdocs.python-cqrs.dev/latest/outbox/databases/#step-4-register-before-the-first-use-not-later).
### Usage
The storage requires an `async_sessionmaker` to create short-lived sessions for each operation.
```python
import uuid
import cqrs
from cqrs.bootstrap import saga as bootstrap
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from cqrs.saga.storage.sqlalchemy import SqlAlchemySagaStorage, Base
# Setup Engine with connection pool
engine = create_async_engine(
"postgresql+asyncpg://user:pass@localhost/db",
pool_size=20,
max_overflow=10,
)
# Create session factory (factory, NOT session instance)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
# Initialize tables (run once)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Initialize storage with session FACTORY
storage = SqlAlchemySagaStorage(session_factory)
# Register saga in SagaMap
def saga_mapper(mapper: cqrs.SagaMap) -> None:
mapper.bind(OrderContext, OrderSaga)
# Create saga mediator using bootstrap
mediator = bootstrap.bootstrap(
di_container=di_container,
sagas_mapper=saga_mapper,
saga_storage=storage,
)
# Execute saga
context = OrderContext(...)
saga_id = uuid.uuid4()
# With SqlAlchemySagaStorage, commits occur at checkpoints (after each step, etc.)
async for step_result in mediator.execute(context, saga_id=saga_id):
print(f"Step: {step_result.step_type.__name__}")
```
### Transaction Management
**SqlAlchemySagaStorage** implements **`create_run()`**: it yields a **`_SqlAlchemySagaStorageRun`** backed by a single **`AsyncSession`** per saga run. All mutations go through that session and are committed only when the saga calls **`run.commit()`** at checkpoints (after creating the saga and setting RUNNING, after each completed step, after each compensated step, at completion or failure). On exception within the run context, the run's **`rollback()`** is invoked.
This design ensures:
- **Fewer commits** — One commit per checkpoint instead of per storage call.
- **Shorter lock time** — With `load_saga_state(..., read_for_update=True)`, the row is locked only for the duration of the run; checkpoint commits shorten that duration and reduce deadlock risk.
- **Crash safety** — Completed checkpoints are persisted; recovery can resume from the last checkpoint.
- **Backward compatibility** — Custom storages that do not implement `create_run()` continue to work; the saga falls back to calling storage methods directly (no single session).
### Concurrency Control
The storage implementation provides two mechanisms to handle concurrency in distributed environments:
#### 1. Optimistic Locking (Versioning)
To prevent "lost updates" when multiple steps might update the context simultaneously (though sagas typically execute sequentially), the `version` column is used.
- `update_context` accepts an optional `current_version`.
- If provided, the storage checks if `version == current_version`.
- If matched, it updates the context and increments the version (`version + 1`).
- If not matched, it raises `SagaConcurrencyError`, indicating the state was modified by another process.
#### 2. Row Locking (Recovery Safety and Deadlock Mitigation)
When recovering or exclusively updating a saga (e.g., after a crash), it is critical that only one worker picks up the saga to avoid duplicate execution.
- `load_saga_state(..., read_for_update=True)` uses `SELECT ... FOR UPDATE` (in SQL databases), acquiring a row-level lock on the saga execution record.
- Other workers attempting to lock the same saga will wait or fail, ensuring exclusive access.
- When the storage supports **`create_run()`**, the saga holds the session (and thus the lock) only between checkpoints; commits are done at key points, which shortens lock duration and reduces deadlock risk.
## Choosing Storage
**Memory Storage:**
- ✅ Unit tests, development
- ❌ Not persistent
**SQLAlchemy Storage:**
- ✅ Production, multi-process
- ✅ Recovery after restarts
- ✅ Audit trail
- ✅ Robust transaction management
**SQLModel Storage:**
Same production behavior as SQLAlchemy storage, including `create_run()`, optimistic locking, and recovery queries. The tables use the same DDL. See [ORM](https://mkdocs.python-cqrs.dev/latest/orm/index.md).
## Best Practices
1. **Use persistent storage in production** — Memory storage loses data on restart
1. **Configure Connection Pool** — Set appropriate `pool_size` and `max_overflow` for your load.
1. **Create indexes** — Index `saga_id` and `created_at` for better performance
1. **Monitor storage size** — Archive old saga logs periodically
# Saga Recovery & Eventual Consistency
- **Back to Saga Overview**
Return to the Saga Pattern overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/saga/index.md)
Recovery ensures eventual consistency by resuming interrupted sagas from persistent storage, guaranteeing all sagas eventually reach a terminal state (COMPLETED or FAILED). Recovery **attempts** are tracked per saga so you can limit retries and exclude persistently failing sagas.
## Overview
Sagas can be interrupted due to server crashes, network timeouts, or system overload. Recovery solves this by:
1. Persisting saga state after each step
1. Periodically scanning for incomplete sagas (via `get_sagas_for_recovery`)
1. Resuming execution from the last completed step
1. Completing compensation if saga was in compensating state
1. Tracking **recovery attempts** — on recovery failure, the storage increments `recovery_attempts` automatically so sagas can be retried or excluded when the limit is reached
## Eventual Consistency
The saga pattern ensures eventual consistency through:
- **Persistent State** — Saved after each step
- **Recovery Mechanism** — Interrupted sagas can be resumed
- **Recovery Attempts** — Each saga has a `recovery_attempts` counter; it is incremented automatically when recovery fails, so you can limit retries and exclude sagas that exceed `max_recovery_attempts`
- **Compensation Guarantee** — Failed sagas are always compensated
- **Terminal States** — All sagas eventually reach COMPLETED or FAILED
## Recovery Process
```python
from cqrs import ScopeStrategy
from cqrs.saga.recovery import recover_saga
# Recover interrupted saga — same strategy, container, and storage as the original run.
# A plain di.Container / DIContainer is enough; do not wrap ScopeAwareContainer yourself.
await recover_saga(
saga=saga,
saga_id=saga_id,
context_builder=OrderContext, # or lambda d: OrderContext(**d)
container=container,
storage=storage,
scope_strategy=ScopeStrategy.SEND, # must match bootstrap / the original stream
)
```
Recovery steps:
1. Load saga status and context from storage
1. Reconstruct context object from persisted data
1. Resume execution from last completed step or complete compensation
### Concurrency Safety (Row Locking)
In a distributed environment with multiple replicas, multiple recovery workers might attempt to recover the same incomplete saga simultaneously.
To prevent race conditions, `recover_saga` uses **Row Locking**:
1. It calls `storage.load_saga_state(saga_id, read_for_update=True)`.
1. For SQL databases, this executes `SELECT ... FOR UPDATE`.
1. This acquires a database-level lock on the saga row.
1. If another worker tries to recover the same saga, it will block until the first worker completes or releases the lock.
This ensures that **only one worker** can actively recover and execute a specific saga at any given time.
## Recovery Scenarios
### Interrupted Forward Execution
**Status:** `RUNNING`\
**Recovery:** Skips completed steps, resumes from last completed step
```python
status, _, _ = await storage.load_saga_state(saga_id) # RUNNING
await recover_saga(
saga, saga_id, OrderContext,
container=container, storage=storage,
scope_strategy=ScopeStrategy.SEND,
)
# Skips completed steps, continues execution
```
### Interrupted Compensation
**Status:** `COMPENSATING`\
**Recovery:** Completes compensation in reverse order
```python
status, _, _ = await storage.load_saga_state(saga_id) # COMPENSATING
try:
await recover_saga(
saga, saga_id, OrderContext,
container=container, storage=storage,
scope_strategy=ScopeStrategy.SEND,
)
except RuntimeError:
pass # Expected - compensation completed
```
### Already Completed
**Status:** `COMPLETED`\
**Recovery:** No action needed
## Recovery Attempts
Each saga in storage has a **recovery_attempts** counter. It is used to:
- **Limit retries** — Sagas that fail recovery repeatedly can be excluded from future recovery runs
- **Avoid infinite loops** — Persistently failing sagas (e.g. due to bad data) stop being picked after `max_recovery_attempts`
**Automatic increment:** When `recover_saga()` fails (exception during resume), the storage's `increment_recovery_attempts(saga_id, new_status=SagaStatus.FAILED)` is called automatically. Callers do **not** need to call `increment_recovery_attempts` themselves.
**Explicit set:** Use `storage.set_recovery_attempts(saga_id, attempts)` to set the counter to a specific value: e.g. `0` after successfully recovering one of the steps, or the maximum value so the saga is excluded from further recovery without changing its status.
**Getting sagas for recovery:** Use `storage.get_sagas_for_recovery()` instead of a custom query:
```python
# All saga types (default)
ids = await storage.get_sagas_for_recovery(
limit=50,
max_recovery_attempts=5, # Only sagas with recovery_attempts < 5
stale_after_seconds=120, # Only sagas not updated in last 2 minutes (avoids picking active sagas)
)
# Only sagas of a specific type (e.g. one recovery job per saga name)
ids = await storage.get_sagas_for_recovery(
limit=50,
max_recovery_attempts=5,
saga_name="OrderSaga",
)
```
| Parameter | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `limit` | Maximum number of saga IDs to return |
| `max_recovery_attempts` | Only include sagas with `recovery_attempts` strictly less than this value (default: 5) |
| `stale_after_seconds` | If set, only include sagas whose `updated_at` is older than (now − this value). Use to avoid picking sagas currently being executed. `None` = no filter |
| `saga_name` | If set, only include sagas with this name (e.g. handler/type name). `None` (default) = return all saga types |
Returns saga IDs in status RUNNING or COMPENSATING, ordered by `updated_at` ascending (oldest first).
## Strict Backward Recovery
Once a saga enters `COMPENSATING` or `FAILED` status, forward execution is **permanently disabled**. Only compensation can proceed.
This prevents "zombie states" where compensation actions conflict with new execution attempts.
## Implementing Recovery
### Background Recovery Job
Use `storage.get_sagas_for_recovery()` to get saga IDs that need recovery. On recovery failure, `recover_saga()` calls `increment_recovery_attempts` internally — no extra code needed. You can pass `saga_name` to run separate recovery jobs per saga type.
```python
import asyncio
from cqrs import ScopeStrategy
from cqrs.saga.recovery import recover_saga
async def recovery_job(storage, saga, context_builder, container, saga_name=None):
while True:
ids = await storage.get_sagas_for_recovery(
limit=50,
max_recovery_attempts=5,
stale_after_seconds=120, # Avoid sagas currently being executed
saga_name=saga_name, # None = all types; or e.g. "OrderSaga" for one type
)
for saga_id in ids:
try:
await recover_saga(
saga=saga,
saga_id=saga_id,
context_builder=context_builder,
container=container,
storage=storage,
scope_strategy=ScopeStrategy.SEND, # same as the original run
)
except RuntimeError:
pass # Expected when compensation completed (forward execution not allowed)
except Exception as e:
logger.error(f"Recovery failed for {saga_id}: {e}")
# recovery_attempts already incremented by recover_saga
await asyncio.sleep(60) # Scan every minute
```
### Using with Scheduler
```python
# APScheduler
from apscheduler.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler()
scheduler.add_job(
lambda: recovery_job(storage, OrderSaga(), OrderContext, container),
'interval',
minutes=5,
)
scheduler.start()
```
## Best Practices
1. **Run recovery periodically** — Background job using `get_sagas_for_recovery()` to scan for incomplete sagas
1. **Use `max_recovery_attempts`** — Exclude sagas that fail recovery too many times (e.g. 5) to avoid infinite retries
1. **Use `stale_after_seconds`** — Avoid picking sagas that are currently being executed by another worker
1. **Use `saga_name` for per-type recovery** — When running separate recovery jobs per saga type, pass `saga_name` so each job only processes its own sagas
1. **Match `scope_strategy`** — Pass the same strategy, `container`, and `storage` as the original run. A plain container is enough; `SagaTransaction` wraps it internally
1. **Do not recover from an open SEND request** — `recover_saga(SEND)` inside `send()` joins that request UoW (`reuse_existing=True`). Run recovery from a worker
1. **Handle failures** — Log errors and send alerts; `increment_recovery_attempts` is called automatically by `recover_saga`
1. **Monitor metrics** — Track recovery rate, duration, failures, and sagas exceeding max attempts
1. **Use persistent storage** — Memory storage loses data on restart
# Compensation Strategy
- **Back to Saga Overview**
Return to the Saga Pattern overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/saga/index.md)
Compensation undoes the effects of completed steps when a saga fails, ensuring resources are properly released and the system returns to a consistent state.
When the storage supports **`create_run()`**, the saga runs compensation within a single storage run and persists each successfully compensated step at a **checkpoint** (by committing the run after each step). This keeps the number of commits low and aligns with the checkpoint model used for forward execution.
## Overview
When any step fails, all previously completed steps are automatically compensated in **reverse order**:
- Resources are properly released
- Partial operations are rolled back
- System returns to a consistent state
## How Compensation Works
Compensation is triggered automatically when a step's `act()` method raises an exception:
```
sequenceDiagram
participant Transaction as SagaTransaction
participant Step1 as Step 1
participant Step2 as Step 2
participant Step3 as Step 3
Transaction->>Step1: act(context)
Step1-->>Transaction: Success ✓
Transaction->>Step2: act(context)
Step2-->>Transaction: Success ✓
Transaction->>Step3: act(context)
Step3-->>Transaction: Exception ✗
Note over Transaction: Compensation triggered
Transaction->>Step2: compensate(context)
Transaction->>Step1: compensate(context)
Transaction-->>Transaction: Mark saga as FAILED
```
Compensation happens in **reverse order**:
```text
Execution: Step 1 → Step 2 → Step 3
Compensation: Step 3 ← Step 2 ← Step 1
```
## Implementing Compensation
Each step handler must implement the `compensate()` method:
```python
class ReserveInventoryStep(SagaStepHandler[OrderContext, Response]):
async def act(self, context: OrderContext) -> SagaStepResult:
reservation_id = await self._inventory_service.reserve_items(
context.order_id, context.items
)
context.inventory_reservation_id = reservation_id
return self._generate_step_result(Response())
async def compensate(self, context: OrderContext) -> None:
if context.inventory_reservation_id:
await self._inventory_service.release_items(
context.inventory_reservation_id
)
```
### Best Practices
1. **Idempotent** — Safe to call multiple times
1. **Check Context** — Verify compensation is needed before executing
1. **Handle Errors** — Gracefully handle missing resources
### Idempotent Example
```python
async def compensate(self, context: OrderContext) -> None:
if not context.inventory_reservation_id:
return # Already compensated
try:
await self._inventory_service.release_items(
context.inventory_reservation_id
)
context.inventory_reservation_id = None
except ValueError:
pass # Already released - idempotent
```
## Compensation Retry
Automatic retry for compensation failures is configured on **`saga.transaction(...)`**:
```python
from cqrs import ScopeStrategy
saga = OrderSaga() # steps defined on class
async with saga.transaction(
context=context,
container=container,
storage=storage,
scope_strategy=ScopeStrategy.SEND, # same as the original run; plain container is enough
compensation_retry_count=3, # Number of retry attempts
compensation_retry_delay=1.0, # Initial delay in seconds
compensation_retry_backoff=2.0, # Exponential backoff multiplier
) as transaction:
async for step_result in transaction:
...
```
**Retry schedule:**
- Attempt 1: fails, wait 1.0s
- Attempt 2: fails, wait 2.0s (1.0 × 2.0)
- Attempt 3: fails, wait 4.0s (2.0 × 2.0)
- Attempt 4: final attempt
## Compensation Patterns
### Direct Reversal
```python
async def act(self, context: OrderContext) -> SagaStepResult:
resource_id = await service.create_resource(data)
context.resource_id = resource_id
return self._generate_step_result(Response())
async def compensate(self, context: OrderContext) -> None:
if context.resource_id:
await service.delete_resource(context.resource_id)
```
### Compensating Transaction
```python
async def act(self, context: OrderContext) -> SagaStepResult:
payment_id = await payment_service.charge(amount)
context.payment_id = payment_id
return self._generate_step_result(Response())
async def compensate(self, context: OrderContext) -> None:
if context.payment_id:
await payment_service.refund(context.payment_id)
```
### No-Op Compensation
```python
async def act(self, context: OrderContext) -> SagaStepResult:
await notification_service.send(context.user_id, message)
return self._generate_step_result(Response())
async def compensate(self, context: OrderContext) -> None:
pass # Notifications can't be "unsent"
```
## Common Pitfalls
### Non-Idempotent Compensation
```python
# ❌ Bad: Not idempotent
async def compensate(self, context: OrderContext) -> None:
await service.delete_resource(context.resource_id)
# ✅ Good: Idempotent
async def compensate(self, context: OrderContext) -> None:
if context.resource_id:
try:
await service.delete_resource(context.resource_id)
except NotFoundError:
pass
context.resource_id = None
```
### Missing Context Check
```python
# ❌ Bad: May fail if resource doesn't exist
async def compensate(self, context: OrderContext) -> None:
await service.release_items(context.inventory_reservation_id)
# ✅ Good: Checks before compensating
async def compensate(self, context: OrderContext) -> None:
if context.inventory_reservation_id:
await service.release_items(context.inventory_reservation_id)
```
## Scoped compensation
- **SEND** — compensation runs on the **same step instance and UoW** as `act` (one saga scope).
- **HANDLER** — each step (and compensation) is re-resolved in a **fresh** scope, so `self` state from `act` is gone. Persist what `compensate` needs in `SagaContext` and keep compensation idempotent.
- **NONE** — no framework scopes.
Do not wrap the container with `ScopeAwareContainer` yourself; `saga.transaction` wraps a plain container internally. See [Scope Strategies](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/strategies/index.md).
## Best Practices
1. **Idempotent** — Safe to call multiple times
1. **Check Context** — Verify compensation is needed
1. **Handle Failures** — Log errors appropriately
1. **Test Logic** — Ensure compensation works correctly
1. **Match `scope_strategy`** — Pass the same strategy as the original run; under HANDLER do not store compensation state on `self`
# Saga Examples
- **Back to Saga Overview**
Return to the Saga Pattern overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/saga/index.md)
## Basic Saga Example
```python
import dataclasses
import uuid
import di
import cqrs
from cqrs import ScopeStrategy
from cqrs.bootstrap import saga as bootstrap
from cqrs.saga.saga import Saga
from cqrs.handlers.saga import SagaStepHandler, SagaStepResult
from cqrs.saga.storage.memory import MemorySagaStorage
from cqrs.saga.models import SagaContext
from cqrs.models.response import Response
@dataclasses.dataclass
class OrderContext(SagaContext):
order_id: str
items: list[str]
total_amount: float
inventory_reservation_id: str | None = None
payment_id: str | None = None
class ReserveInventoryStep(SagaStepHandler[OrderContext, Response]):
def __init__(self, inventory_service):
self._inventory_service = inventory_service
async def act(self, context: OrderContext) -> SagaStepResult:
reservation_id = await self._inventory_service.reserve_items(
context.order_id, context.items
)
context.inventory_reservation_id = reservation_id
return self._generate_step_result(Response())
async def compensate(self, context: OrderContext) -> None:
if context.inventory_reservation_id:
await self._inventory_service.release_items(
context.inventory_reservation_id
)
# Define saga class with steps
class OrderSaga(Saga[OrderContext]):
steps = [ReserveInventoryStep, ProcessPaymentStep]
# Setup DI container
di_container = di.Container()
# ... register services ...
# Create saga storage
storage = MemorySagaStorage()
# Register saga in SagaMap
def saga_mapper(mapper: cqrs.SagaMap) -> None:
mapper.bind(OrderContext, OrderSaga)
# Create saga mediator using bootstrap
mediator = bootstrap.bootstrap(
di_container=di_container,
sagas_mapper=saga_mapper,
saga_storage=storage,
scope_strategy=ScopeStrategy.SEND, # sequential events; no concurrent=False needed
)
# Execute saga — exhaust or aclose(); abandoned SEND holds UoW until GC
from contextlib import aclosing
context = OrderContext(order_id="123", items=["item_1"], total_amount=100.0)
saga_id = uuid.uuid4()
async with aclosing(mediator.execute(context, saga_id=saga_id)) as stream:
async for step_result in stream:
print(f"Step completed: {step_result.step_type.__name__}")
```
**Complete example:** [`examples/saga/saga.py`](https://github.com/vadikko2/python-cqrs/blob/master/examples/saga/saga.py)
______________________________________________________________________
## Recovery Example
```python
from cqrs import ScopeStrategy
from cqrs.saga.recovery import recover_saga
# Get saga instance (or keep reference to saga class)
saga = OrderSaga()
# Recover interrupted saga — same container, storage, and scope_strategy as bootstrap.
# A plain di.Container is enough; do not wrap ScopeAwareContainer yourself.
saga_id = uuid.UUID("550e8400-e29b-41d4-a716-446655440000")
try:
await recover_saga(
saga=saga,
saga_id=saga_id,
context_builder=OrderContext,
container=di_container, # Same container used in bootstrap
storage=storage,
scope_strategy=ScopeStrategy.SEND, # same strategy as the original run
)
print("Saga recovered successfully!")
except RuntimeError:
# Expected if saga was in COMPENSATING/FAILED state
print("Compensation completed")
```
**Complete example:** [`examples/saga/saga_recovery.py`](https://github.com/vadikko2/python-cqrs/blob/master/examples/saga/saga_recovery.py)
______________________________________________________________________
## FastAPI SSE Integration
```python
import fastapi
import json
import uuid
from cqrs import ScopeStrategy
from cqrs.bootstrap import saga as bootstrap
def mediator_factory() -> cqrs.SagaMediator:
"""Create saga mediator using bootstrap."""
return bootstrap.bootstrap(
di_container=di_container,
sagas_mapper=saga_mapper,
saga_storage=storage,
scope_strategy=ScopeStrategy.SEND,
)
@app.post("/process-order")
async def process_order(
request: ProcessOrderRequest,
mediator: cqrs.SagaMediator = fastapi.Depends(mediator_factory),
):
async def generate_sse():
saga_id = uuid.uuid4()
context = OrderContext(...)
yield f"data: {json.dumps({'type': 'start', 'saga_id': str(saga_id)})}\n\n"
async for step_result in mediator.execute(context, saga_id=saga_id):
yield f"data: {json.dumps({'type': 'step_progress', 'step': step_result.step_type.__name__})}\n\n"
yield f"data: {json.dumps({'type': 'complete'})}\n\n"
return fastapi.responses.StreamingResponse(
generate_sse(), media_type="text/event-stream"
)
```
**Complete example:** [`examples/saga/saga_fastapi_sse.py`](https://github.com/vadikko2/python-cqrs/blob/master/examples/saga/saga_fastapi_sse.py)
______________________________________________________________________
## SQLAlchemy Storage Example
```python
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from cqrs import ScopeStrategy
from cqrs.saga.storage.sqlalchemy import SqlAlchemySagaStorage, Base
from cqrs.bootstrap import saga as bootstrap
# Setup
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def create_storage() -> SqlAlchemySagaStorage:
return SqlAlchemySagaStorage(SessionLocal())
# Usage
storage = await create_storage()
# Register saga in SagaMap
def saga_mapper(mapper: cqrs.SagaMap) -> None:
mapper.bind(OrderContext, OrderSaga)
# Create saga mediator using bootstrap
mediator = bootstrap.bootstrap(
di_container=di_container,
sagas_mapper=saga_mapper,
saga_storage=storage,
scope_strategy=ScopeStrategy.SEND,
)
# Execute saga — exhaust or aclose()
from contextlib import aclosing
context = OrderContext(...)
saga_id = uuid.uuid4()
async with aclosing(mediator.execute(context, saga_id=saga_id)) as stream:
async for step_result in stream:
print(f"Step: {step_result.step_type.__name__}")
await storage.session.commit()
```
______________________________________________________________________
## Compensation Retry Configuration
Compensation retry configuration is handled at the transaction level. When using `SagaMediator`, retry settings can be configured when creating the saga transaction. However, the recommended approach is to configure retry settings in your saga class or use the default settings.
For advanced retry configuration, you can access the transaction directly:
```python
# Note: Compensation retry is configured at the SagaTransaction level
# When using mediator.execute(), default retry settings are used
# For custom retry configuration, you may need to access the transaction directly
```
## Background Recovery Job
```python
import asyncio
from cqrs import ScopeStrategy
from cqrs.saga.recovery import recover_saga
async def recovery_job():
saga = OrderSaga() # Get saga instance
while True:
incomplete_sagas = await find_incomplete_sagas()
for saga_id in incomplete_sagas:
try:
await recover_saga(
saga=saga,
saga_id=saga_id,
context_builder=OrderContext,
container=di_container,
storage=storage,
scope_strategy=ScopeStrategy.SEND,
)
except RuntimeError:
pass # Compensation completed
await asyncio.sleep(60) # Scan every minute
```
## Fallback Pattern Example
```python
import dataclasses
import uuid
import di
from di import dependent
import cqrs
from cqrs import ScopeStrategy
from cqrs.bootstrap import saga as bootstrap
from cqrs.saga.fallback import Fallback
from cqrs.adapters.circuit_breaker import AioBreakerAdapter
from cqrs.saga.saga import Saga
from cqrs.handlers.saga import SagaStepHandler, SagaStepResult
from cqrs.saga.storage.memory import MemorySagaStorage
from cqrs.saga.models import SagaContext
from cqrs.models.response import Response
@dataclasses.dataclass
class OrderContext(SagaContext):
order_id: str
reservation_id: str | None = None
class ReserveInventoryResponse(Response):
reservation_id: str
source: str
class PrimaryStep(SagaStepHandler[OrderContext, ReserveInventoryResponse]):
async def act(self, context: OrderContext) -> SagaStepResult[OrderContext, ReserveInventoryResponse]:
# Primary step that may fail
raise RuntimeError("Service unavailable")
class FallbackStep(SagaStepHandler[OrderContext, ReserveInventoryResponse]):
async def act(self, context: OrderContext) -> SagaStepResult[OrderContext, ReserveInventoryResponse]:
# Fallback step executes when primary fails
reservation_id = f"fallback_reservation_{context.order_id}"
context.reservation_id = reservation_id
return self._generate_step_result(
ReserveInventoryResponse(reservation_id=reservation_id, source="fallback")
)
class OrderSaga(Saga[OrderContext]):
steps = [
Fallback(
step=PrimaryStep,
fallback=FallbackStep,
circuit_breaker=AioBreakerAdapter(
fail_max=2,
timeout_duration=60,
),
),
]
# Setup
di_container = di.Container()
storage = MemorySagaStorage()
def saga_mapper(mapper: cqrs.SagaMap) -> None:
mapper.bind(OrderContext, OrderSaga)
mediator = bootstrap.bootstrap(
di_container=di_container,
sagas_mapper=saga_mapper,
saga_storage=storage,
scope_strategy=ScopeStrategy.SEND, # fallback shares the saga UoW
)
# Execute
from contextlib import aclosing
context = OrderContext(order_id="123")
saga_id = uuid.uuid4()
async with aclosing(mediator.execute(context, saga_id=saga_id)) as stream:
async for step_result in stream:
print(f"Step: {step_result.step_type.__name__}")
```
**Complete example:** [`examples/saga/saga_fallback.py`](https://github.com/vadikko2/python-cqrs/blob/master/examples/saga/saga_fallback.py)
______________________________________________________________________
## More Examples
- [Basic Saga](https://github.com/vadikko2/python-cqrs/blob/master/examples/saga/saga.py)
- [Recovery](https://github.com/vadikko2/python-cqrs/blob/master/examples/saga/saga_recovery.py)
- [FastAPI SSE](https://github.com/vadikko2/python-cqrs/blob/master/examples/saga/saga_fastapi_sse.py)
- [Fallback Pattern](https://github.com/vadikko2/python-cqrs/blob/master/examples/saga/saga_fallback.py)
# Saga Fallback Pattern
- **Back to Saga Overview**
Return to the Saga Pattern overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/saga/index.md)
The Fallback pattern allows you to define alternative steps that execute automatically when primary steps fail. This provides resilience and graceful degradation for distributed transactions.
## Overview
- **Mechanics & Internals**
Learn about execution flow, context snapshots, and compensation logic.
[Read More](https://mkdocs.python-cqrs.dev/latest/saga/fallback/mechanics/index.md)
- **Circuit Breaker**
Understand how circuit breaker integration prevents cascading failures.
[Read More](https://mkdocs.python-cqrs.dev/latest/saga/fallback/circuit_breaker/index.md)
- **Examples**
See complete working examples of the Fallback pattern in action.
[Read More](https://mkdocs.python-cqrs.dev/latest/saga/fallback/examples/index.md)
The `Fallback` wrapper enables saga steps to have backup execution paths. When a primary step fails, the fallback step executes automatically with the context restored to its state before the primary step attempted execution.
### Key Concepts
| Concept | Description |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Primary Step** | The main step handler that executes first |
| **Fallback Step** | Alternative step handler that executes if primary fails |
| **Context Snapshot** | Deep copy of context state before primary step execution |
| **Context Restore** | Restoring context to snapshot state before fallback execution |
| **Circuit Breaker** | Optional protection mechanism to prevent cascading failures |
| **SEND fallback** | Same UoW as the primary step (and domain events). After a DB error the session may need `rollback()` / a savepoint. |
| **HANDLER fallback** | New scope after the primary is **rolled back** (exception re-raised through the generator UoW). Primary writes are not visible; persist step state in `SagaContext`. |
| **NONE** | No framework scopes. |
When to Use
Use Fallback pattern when:
- You have alternative execution paths for critical operations
- You want graceful degradation instead of immediate failure
- You need to protect against transient failures
- You want to reduce load on failing services with Circuit Breaker
## Basic Example
```python
import dataclasses
from cqrs.saga.fallback import Fallback
from cqrs.saga.saga import Saga
from cqrs.handlers.saga import SagaStepHandler, SagaStepResult
from cqrs.saga.models import SagaContext
from cqrs.models.response import Response
@dataclasses.dataclass
class OrderContext(SagaContext):
order_id: str
reservation_id: str | None = None
class ReserveInventoryResponse(Response):
reservation_id: str
class PrimaryStep(SagaStepHandler[OrderContext, ReserveInventoryResponse]):
async def act(self, context: OrderContext) -> SagaStepResult[OrderContext, ReserveInventoryResponse]:
# Primary step that may fail
reservation_id = await self._inventory_service.reserve_items(context.order_id)
context.reservation_id = reservation_id
return self._generate_step_result(
ReserveInventoryResponse(reservation_id=reservation_id)
)
class FallbackStep(SagaStepHandler[OrderContext, ReserveInventoryResponse]):
async def act(self, context: OrderContext) -> SagaStepResult[OrderContext, ReserveInventoryResponse]:
# Alternative step that executes when primary fails
# Context is restored to state before primary execution
reservation_id = f"fallback_reservation_{context.order_id}"
context.reservation_id = reservation_id
return self._generate_step_result(
ReserveInventoryResponse(reservation_id=reservation_id)
)
# Define saga with fallback
class OrderSaga(Saga[OrderContext]):
steps = [
Fallback(
step=PrimaryStep,
fallback=FallbackStep,
),
]
```
## Best Practices
1. **Use Fallback for Resilience**: Define fallback steps for critical operations that have alternative execution paths
1. **Context Isolation**: Remember that fallback receives a restored context (no side effects from failed primary)
1. **Circuit Breaker for Transient Failures**: Use Circuit Breaker when failures are likely transient and you want to reduce load on failing services
1. **Exclude Business Exceptions**: Use `exclude` parameter to prevent business logic errors from opening the circuit
1. **Idempotent Fallback Steps**: Ensure fallback steps are idempotent (safe to retry during recovery)
1. **Proper Compensation**: Define `compensate()` methods for both primary and fallback steps
1. **Failure Exception Filtering**: Use `failure_exceptions` to control which exceptions trigger fallback
1. **Match the scope strategy**: SEND shares the saga UoW with fallback; HANDLER rolls the primary back first. There is no extra fallback-scope flag.
# Circuit Breaker Integration
- **Back to Saga Fallback Overview**
Return to the Saga Fallback overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/saga/fallback/index.md)
Circuit Breaker prevents cascading failures by "opening" the circuit after repeated failures, failing fast instead of attempting execution.
## Why Circuit Breaker?
When a service is experiencing issues:
- **Without Circuit Breaker**: Every request attempts execution, wasting resources and potentially making the problem worse
- **With Circuit Breaker**: After threshold failures, requests fail immediately without attempting execution, reducing load on failing service
## Circuit Breaker States
```
stateDiagram-v2
[*] --> CLOSED: Initial state
CLOSED --> OPEN: Failures >= fail_max
OPEN --> HALF_OPEN: timeout_duration elapsed
HALF_OPEN --> CLOSED: Success
HALF_OPEN --> OPEN: Failure
note right of OPEN
Fail fast mode:
No execution attempted
Fallback executes immediately
end note
note right of HALF_OPEN
Test mode:
Single request allowed
Determines if service recovered
end note
```
## Circuit Breaker Strategy
The Circuit Breaker follows a **three-state pattern**:
1. **CLOSED** (Normal Operation)
1. Primary step executes normally
1. Failures are counted
1. After `fail_max` failures → transition to OPEN
1. **OPEN** (Fail Fast)
1. Primary step is **NOT executed** (fail fast)
1. `CircuitBreakerError` is raised immediately
1. Fallback executes automatically
1. After `timeout_duration` → transition to HALF_OPEN
1. **HALF_OPEN** (Testing Recovery)
1. Single request allowed (test if service recovered)
1. Success → transition to CLOSED
1. Failure → transition back to OPEN
## Configuration
```python
from cqrs.adapters.circuit_breaker import AioBreakerAdapter
from cqrs.saga.fallback import Fallback
Fallback(
step=PrimaryStep,
fallback=FallbackStep,
circuit_breaker=AioBreakerAdapter(
fail_max=3, # Open circuit after 3 failures
timeout_duration=60, # Wait 60 seconds before retry (HALF_OPEN)
exclude=[BusinessError], # Don't count these as failures
),
)
```
**Parameters:**
| Parameter | Description | Default |
| ------------------ | -------------------------------------------- | ------- |
| `fail_max` | Number of failures before opening circuit | `5` |
| `timeout_duration` | Seconds to wait before attempting HALF_OPEN | `60` |
| `exclude` | Exception types that don't count as failures | `[]` |
## Storage Configuration (Memory vs Redis)
By default, the circuit breaker uses in-memory storage, which is isolated to each application instance. For distributed applications, you can configure it to use Redis to share circuit state across all instances.
**In-Memory Storage (Default):**
```python
circuit_breaker = AioBreakerAdapter(
fail_max=3,
timeout_duration=60
)
```
**Redis Storage (Distributed):**
```python
import redis
from aiobreaker.storage.redis import CircuitRedisStorage
from aiobreaker import CircuitBreakerState
def redis_storage_factory(name: str):
# Important: decode_responses=False for aiobreaker compatibility
client = redis.from_url(
"redis://localhost:6379",
encoding="utf-8",
decode_responses=False
)
return CircuitRedisStorage(
state=CircuitBreakerState.CLOSED,
redis_object=client,
namespace=name
)
circuit_breaker = AioBreakerAdapter(
fail_max=3,
timeout_duration=60,
storage_factory=redis_storage_factory
)
```
## Failure Exception Filtering
You can specify which exceptions should trigger fallback execution:
```python
Fallback(
step=PrimaryStep,
fallback=FallbackStep,
failure_exceptions=(ConnectionError, TimeoutError), # Only these trigger fallback
)
```
**Behavior:**
- If `failure_exceptions` is empty tuple `()`: **All exceptions** trigger fallback (default)
- If `failure_exceptions` is specified: **Only matching exceptions** trigger fallback
- Non-matching exceptions propagate immediately (saga fails without fallback)
## Business Exception Exclusion
Some exceptions shouldn't open the circuit (e.g., business logic errors):
```python
class InventoryOutOfStockError(Exception):
pass
Fallback(
step=PrimaryStep,
fallback=FallbackStep,
circuit_breaker=AioBreakerAdapter(
fail_max=3,
timeout_duration=60,
exclude=[InventoryOutOfStockError], # Don't count as failure
),
)
```
**Behavior:**
- `InventoryOutOfStockError` → Fallback executes, but circuit counter is **not incremented**
- Other exceptions → Fallback executes, circuit counter **is incremented**
# Complete Example
- **Back to Saga Fallback Overview**
Return to the Saga Fallback overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/saga/fallback/index.md)
Here is a complete example demonstrating the Fallback pattern with Circuit Breaker and Saga execution.
```python
import dataclasses
import uuid
import di
from di import dependent
import cqrs
from cqrs.bootstrap import saga as bootstrap
from cqrs.saga.fallback import Fallback
from cqrs.adapters.circuit_breaker import AioBreakerAdapter
from cqrs.saga.saga import Saga
from cqrs.handlers.saga import SagaStepHandler, SagaStepResult
from cqrs.saga.storage.memory import MemorySagaStorage
from cqrs.saga.models import SagaContext
from cqrs.models.response import Response
@dataclasses.dataclass
class OrderContext(SagaContext):
order_id: str
user_id: str
reservation_id: str | None = None
class ReserveInventoryResponse(Response):
reservation_id: str
source: str # "primary" or "fallback"
class PrimaryStep(SagaStepHandler[OrderContext, ReserveInventoryResponse]):
def __init__(self, inventory_service):
self._inventory_service = inventory_service
async def act(self, context: OrderContext) -> SagaStepResult[OrderContext, ReserveInventoryResponse]:
# Primary step - may fail
reservation_id = await self._inventory_service.reserve_items(
context.order_id
)
context.reservation_id = reservation_id
return self._generate_step_result(
ReserveInventoryResponse(reservation_id=reservation_id, source="primary")
)
async def compensate(self, context: OrderContext) -> None:
if context.reservation_id:
await self._inventory_service.release_items(context.reservation_id)
class FallbackStep(SagaStepHandler[OrderContext, ReserveInventoryResponse]):
def __init__(self, fallback_service):
self._fallback_service = fallback_service
async def act(self, context: OrderContext) -> SagaStepResult[OrderContext, ReserveInventoryResponse]:
# Fallback step - executes when primary fails
# Context is restored to state before primary execution
reservation_id = f"fallback_reservation_{context.order_id}"
context.reservation_id = reservation_id
return self._generate_step_result(
ReserveInventoryResponse(reservation_id=reservation_id, source="fallback")
)
async def compensate(self, context: OrderContext) -> None:
if context.reservation_id:
await self._fallback_service.release_fallback_reservation(context.reservation_id)
class OrderSaga(Saga[OrderContext]):
steps = [
Fallback(
step=PrimaryStep,
fallback=FallbackStep,
circuit_breaker=AioBreakerAdapter(
fail_max=2,
timeout_duration=60,
),
),
]
# Setup
di_container = di.Container()
# ... register services ...
storage = MemorySagaStorage()
def saga_mapper(mapper: cqrs.SagaMap) -> None:
mapper.bind(OrderContext, OrderSaga)
mediator = bootstrap.bootstrap(
di_container=di_container,
sagas_mapper=saga_mapper,
saga_storage=storage,
scope_strategy=cqrs.ScopeStrategy.SEND, # fallback shares the saga UoW
)
# Execute — exhaust or aclose()
from contextlib import aclosing
context = OrderContext(order_id="123", user_id="user_1")
saga_id = uuid.uuid4()
async with aclosing(mediator.execute(context, saga_id=saga_id)) as stream:
async for step_result in stream:
print(f"Step: {step_result.step_type.__name__}")
if hasattr(step_result.response, "source"):
print(f"Source: {step_result.response.source}")
```
# Mechanics & Internals
- **Back to Saga Fallback Overview**
Return to the Saga Fallback overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/saga/fallback/index.md)
## How It Works
### Execution Flow
```
sequenceDiagram
participant Executor as FallbackStepExecutor
participant Primary as PrimaryStep
participant Fallback as FallbackStep
Executor->>Executor: Create context snapshot
alt Primary Step Succeeds
Executor->>Primary: act(context)
Primary-->>Executor: Success
Note over Executor: Return primary result
else Primary fails under SEND or NONE
Executor->>Primary: act(context)
Primary-->>Executor: Exception
Note over Executor: Catch inside the same scope — do not re-raise through it
Executor->>Executor: Restore context from snapshot
Executor->>Fallback: act(restored_context) in the same UoW
else Primary fails under HANDLER
Executor->>Primary: act(context)
Primary-->>Executor: Exception
Note over Executor: Re-raise through primary handler_scope so generator UoW rolls back
Executor->>Executor: Restore context from snapshot
Executor->>Fallback: act(restored_context) in a new scope
end
```
HANDLER rolls the primary back
Under `HANDLER` the framework does **not** swallow the primary error inside the step scope. The exception is re-raised through `handler_scope` so a generator UoW (`yield session; commit()`) sees `except` and rolls back. Then fallback opens a **new** scope. Under `SEND` / `NONE` the error is caught inside the outer scope so fallback shares the same UoW. See [Scope Strategies](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/strategies/index.md).
### Context Management
The Fallback pattern implements a **snapshot and restore** mechanism for context management:
1. **Before Primary Execution**: A deep copy of the context is created (`copy.deepcopy(context.to_dict())`)
1. **If Primary Fails**: The context is restored to the snapshot state before fallback execution
1. **If Primary Succeeds**: The snapshot is discarded and context updates from primary step are kept
This ensures that:
- Fallback steps start with a clean **SagaContext** (no side effects from failed primary)
- Context mutations from primary step are not visible to fallback
- Each step execution is isolated
- **UoW is separate from SagaContext**: SEND shares the session (it may be dirty / `needs rollback`); HANDLER rolls the primary back, then opens a new scope for fallback
```python
# Simplified context snapshot/restore logic
context_snapshot = copy.deepcopy(context.to_dict()) # Before primary
try:
result = await primary_step.act(context) # May modify context
except Exception:
# Restore context to snapshot state
restored_context = context.__class__.from_dict(context_snapshot)
for field in dataclasses.fields(context):
setattr(context, field.name, getattr(restored_context, field.name))
# Execute fallback with restored context
result = await fallback_step.act(context)
```
## Step Execution Details
### Primary Step Execution
1. **Context Snapshot**: Deep copy created before execution
1. **Logging**: Step start logged to `SagaLog`
1. **Execution**:
1. If Circuit Breaker present: `circuit_breaker.call(step_type, primary_step.act, context)`
1. Otherwise: `primary_step.act(context)` directly
1. **Success**: Context updated, step completion logged
1. **Failure**: Exception caught. Under **HANDLER** it is re-raised through the primary `handler_scope` (rollback), then fallback runs in a new scope. Under **SEND** / **NONE** fallback runs in the same UoW.
### Fallback Step Execution
1. **Context Restore**: Context restored to snapshot state (before primary execution)
1. **Logging**: Fallback step start logged
1. **Execution**: `fallback_step.act(restored_context)`
1. **Success**: Context updated, fallback completion logged
1. **Failure**: Exception propagated (saga fails)
### Idempotency
Fallback steps respect idempotency checks:
- If primary step name is in `completed_step_names` → Skip execution
- If fallback step name is in `completed_step_names` → Skip execution
- This ensures recovery doesn't re-execute already completed steps
## Compensation
Both primary and fallback steps can define `compensate()` methods:
```python
class PrimaryStep(SagaStepHandler[OrderContext, ReserveInventoryResponse]):
async def act(self, context: OrderContext) -> SagaStepResult:
# ... primary logic ...
async def compensate(self, context: OrderContext) -> None:
# Compensate primary step
if context.reservation_id:
await self._inventory_service.release_items(context.reservation_id)
class FallbackStep(SagaStepHandler[OrderContext, ReserveInventoryResponse]):
async def act(self, context: OrderContext) -> SagaStepResult:
# ... fallback logic ...
async def compensate(self, context: OrderContext) -> None:
# Compensate fallback step
if context.reservation_id:
await self._inventory_service.release_fallback_reservation(context.reservation_id)
```
**Compensation Rules:**
- Only the **actually executed step** (primary or fallback) is compensated
- If primary succeeded → only primary's `compensate()` is called
- If fallback executed → only fallback's `compensate()` is called
- Under **SEND**, compensation uses the same step instance and UoW as `act`. Under **HANDLER**, the step is re-resolved in a fresh scope — persist what compensation needs in `SagaContext`.
## Storage and Logging
Fallback execution is fully logged in `SagaLog`:
**Successful Primary:**
```text
- primary_step.act STARTED
- primary_step.act COMPLETED
```
**Failed Primary → Successful Fallback:**
```text
- primary_step.act STARTED
- fallback_step.act STARTED
- fallback_step.act COMPLETED
```
**Failed Primary → Failed Fallback:**
```text
- primary_step.act STARTED
- fallback_step.act STARTED
- fallback_step.act FAILED (error details)
```
**Circuit Breaker OPEN:**
```text
- fallback_step.act STARTED (primary not executed)
- fallback_step.act COMPLETED
```
# Events and outbox
# Event Handling
## Overview
- **Event Flow**
Understanding how events flow through the system from handlers to processing.
[Read More](https://mkdocs.python-cqrs.dev/latest/event_handler/event_flow/index.md)
- **Runtime Processing**
How events are processed synchronously in the same request context.
[Read More](https://mkdocs.python-cqrs.dev/latest/event_handler/runtime_processing/index.md)
- **Parallel Processing**
Configuring parallel event processing with concurrency limits.
[Read More](https://mkdocs.python-cqrs.dev/latest/event_handler/parallel_processing/index.md)
- **Event Types**
DomainEvent vs NotificationEvent and when to use each type.
[Read More](https://mkdocs.python-cqrs.dev/latest/event_handler/event_types/index.md)
- **Examples**
Complete examples of event handling patterns.
[Read More](https://mkdocs.python-cqrs.dev/latest/event_handler/examples/index.md)
- **Best Practices**
Best practices and recommendations for event handling.
[Read More](https://mkdocs.python-cqrs.dev/latest/event_handler/best_practices/index.md)
- **Fallback**
Fallback handler when primary event handler fails or circuit breaker is open.
[Read More](https://mkdocs.python-cqrs.dev/latest/event_handler/fallback/index.md)
Event handlers process domain events that are emitted from command handlers. These events represent something that happened in the domain and trigger side effects like sending notifications, updating read models, or triggering other workflows.
When a command handler processes a request, it can emit domain events through the `events` property. These events are automatically collected and processed by event handlers registered in the system. **Event handlers** can in turn produce **follow-up events** via their own `events` property; these follow-ups are processed in the same pipeline (sequential BFS or parallel with semaphore), enabling multi-level event chains.
| Aspect | Description |
| ---------------------- | ------------------------------------------------------------------------------------------------------- |
| **Runtime Processing** | Events are processed synchronously in the same request context, not asynchronously |
| **Automatic Dispatch** | Events are automatically dispatched to registered handlers after command execution |
| **Event Propagation** | Handlers can return follow-up events via `events`; they are processed in the same run (BFS or parallel) |
| **Parallel Support** | Multiple events can be processed in parallel with configurable concurrency limits |
| **Side Effects** | Event handlers perform side effects without blocking the main command flow |
Prerequisites
Understanding of [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md) and [Bootstrap](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md) is required. Events are emitted by command handlers and processed by event handlers.
Related Topics
- [Transaction Outbox](https://mkdocs.python-cqrs.dev/latest/outbox/index.md) — For reliable event delivery to message brokers
- [Event Producing](https://mkdocs.python-cqrs.dev/latest/event_producing/index.md) — For publishing events to Kafka/RabbitMQ
- [FastStream Integration](https://mkdocs.python-cqrs.dev/latest/faststream/index.md) — For consuming events from message brokers
# Best Practices
- **Back to Event Handling Overview**
Return to the Event Handling overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md)
______________________________________________________________________
## Overview
| Practice | Description | Impact |
| --------------------------- | -------------------------------------------------------------------------------------------------- | ------------------- |
| **Keep handlers fast** | Event handlers execute synchronously, so keep them fast | Performance |
| **Handle errors** | Implement error handling in event handlers | Reliability |
| **Use parallel processing** | Enable parallel processing for independent events | Performance |
| **Limit concurrency** | Set appropriate `max_concurrent_event_handlers` based on resources | Resource management |
| **Idempotency** | Make event handlers idempotent when possible | Reliability |
| **Logging** | Log important events for debugging and monitoring | Observability |
| **Follow-up events** | Use `handler.events` for multi-level chains; follow-ups run in the same pipeline (BFS or parallel) | Design clarity |
Performance Considerations
Event handlers execute synchronously in the request context. Keep them fast to avoid blocking the main request flow.
Parallel Processing
Enable parallel event processing for independent events to improve performance. Set `concurrent_event_handle_enable=True` and configure `max_concurrent_event_handlers`.
Event handling in `python-cqrs`:
- **Runtime Processing** — Events are processed synchronously in the same request context
- **Automatic Dispatch** — Events are automatically dispatched to registered handlers
- **Event Propagation** — Handlers can return follow-up events via `events`; they are processed in the same pipeline (BFS or parallel with semaphore)
- **Parallel Support** — Multiple events can be processed in parallel with configurable limits
- **Two Types** — DomainEvent (in-process) and NotificationEvent (message broker)
- **Side Effects** — Event handlers perform side effects without blocking command execution
Use event handlers to implement side effects like notifications, read model updates, and workflow triggers while keeping your command handlers focused on business logic.
# Event Flow
- **Back to Event Handling Overview**
Return to the Event Handling overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md)
______________________________________________________________________
## Overview
The event handling flow follows these steps:
### High-Level Flow
```
sequenceDiagram
autonumber
participant Client
participant Mediator
participant Handler as Command Handler
participant Events as Events Collection
participant Processor as Event Processor
participant Emitter as Event Emitter
participant Handlers as Event Handlers
participant Broker as Message Broker
Client->>Mediator: 1. Send Command
Mediator->>Handler: 2. Execute Handler
Handler->>Handler: 3. Business Logic
Handler->>Events: 4. Collect Events
Handler-->>Mediator: 5. Return Response
Mediator->>Processor: 6. Emit Events
Processor->>Emitter: 7. Emit Each Event
alt DomainEvent
Emitter->>Handlers: 8. Execute Event Handlers
Handlers-->>Emitter: 9. handler.events (follow-ups)
Emitter-->>Processor: 10. Return follow-ups
Note over Processor: Process follow-ups in same pipeline (BFS or parallel)
else NotificationEvent
Emitter->>Broker: 8. Send to Message Broker
Broker-->>Emitter: 9. Complete
Emitter-->>Processor: 10. No follow-ups
end
Processor-->>Mediator: 11. Complete
Mediator-->>Client: 12. Return Response
```
Follow-up events
For domain events, handlers can return follow-up events via the `events` property. The processor continues emitting these in the same pipeline (sequential BFS or parallel with semaphore) until the queue is empty.
### Detailed Event Processing Flow
```
graph TD
A[Command Handler Executes] -->|Collect Events| B[Events Property]
B -->|Return Events| C[RequestMediator]
C -->|Has Events?| D{Events Exist?}
D -->|No| E[Return Response]
D -->|Yes| F[EventProcessor.emit_events]
F -->|Queue: initial events| G{Parallel Enabled?}
G -->|No| H[Sequential: BFS]
G -->|Yes| I[Parallel: Semaphore + FIRST_COMPLETED]
H --> J[Pop Event from Queue]
I --> J
J --> K[EventEmitter.emit]
K --> L{Event Type?}
L -->|DomainEvent| M[EventMap Lookup]
L -->|NotificationEvent| N[Send to Broker]
M --> O[Resolve Handler from DI]
O --> P[Execute handler.handle]
P --> Q[Collect handler.events - follow-ups]
Q --> R[Return follow-ups to Processor]
N --> R
R --> S{More in Queue?}
S -->|Yes| G
S -->|No| E
style A fill:#e1f5ff
style B fill:#fff3e0
style F fill:#c8e6c9
style P fill:#c8e6c9
style Q fill:#fff9c4
style E fill:#f3e5f5
```
### 1. Event Collection
Command handlers collect events in the `events` property:
```python
class JoinMeetingCommandHandler(RequestHandler[JoinMeetingCommand, None]):
def __init__(self):
self._events: list[Event] = []
@property
def events(self) -> list[Event]:
return self._events
async def handle(self, request: JoinMeetingCommand) -> None:
# Business logic
STORAGE[request.meeting_id].append(request.user_id)
# Collect domain event
self._events.append(
UserJoined(user_id=request.user_id, meeting_id=request.meeting_id)
)
```
### 2. Event Emission
After the command handler completes, the mediator collects events and emits them through EventProcessor:
```python
dispatch_result = await self._dispatcher.dispatch(request)
# Events are emitted through EventProcessor
# EventProcessor uses EventEmitter which handles:
# - DomainEvent: processes via event handlers (in-process)
# - NotificationEvent: sends to message broker
await self._event_processor.emit_events(dispatch_result.events)
```
The `EventProcessor` handles parallel or sequential processing based on configuration, and `EventEmitter` routes events to appropriate handlers or message brokers.
### 3. Event Processing via EventEmitter
Events are processed through `EventEmitter`, which routes them based on event type. For domain events, after each handler runs, follow-up events from `handler.events` are collected and returned; the processor then continues with these in the same pipeline (BFS in sequential mode, or under the same semaphore in parallel mode).
```
graph TD
A[EventEmitter.emit] -->|1. Get Event Type| B{Event Type?}
B -->|DomainEvent| C[EventMap.get]
C -->|2. Find Handlers| D{Handlers Found?}
D -->|No| E[Log Warning]
D -->|Yes| F[Loop Through Handlers]
F -->|3. Resolve Handler| G[DI Container]
G -->|4. Execute handler.handle| H[Handler.handle]
H -->|5. Collect handler.events| I[Follow-up events]
I --> J[Return follow-ups to Processor]
B -->|NotificationEvent| K{Message Broker?}
K -->|No| L[Raise RuntimeError]
K -->|Yes| M[Send to Message Broker]
M --> N[Return empty - no follow-ups]
style A fill:#e1f5ff
style H fill:#c8e6c9
style I fill:#fff9c4
style J fill:#fff3e0
```
### 3.1. Follow-up events from event handlers (event propagation)
Event handlers can produce **follow-up events** by implementing the `events` property. After `handle()` is called, the emitter reads `handler.events` and returns them to the processor. These follow-ups are processed in the **same pipeline**:
| Mode | Behavior |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sequential** (`concurrent_event_handle_enable=False`) | Events and follow-ups are processed in **BFS order**: one event at a time, then its follow-ups are appended to the queue. |
| **Parallel** (`concurrent_event_handle_enable=True`) | Events are processed under a semaphore; as soon as any task completes, its follow-ups are queued and started (FIRST_COMPLETED), without waiting for sibling events. |
This allows **multi-level event chains**: e.g. `OrderCreated` → handler emits `InventoryReserved` → handler emits `NotificationScheduled`, all in one run.
Example: handler that produces a follow-up event:
```python
class OrderCreatedEventHandler(cqrs.EventHandler[OrderCreatedEvent]):
def __init__(self) -> None:
self._follow_ups: list[cqrs.IEvent] = []
@property
def events(self) -> typing.Sequence[cqrs.IEvent]:
return tuple(self._follow_ups)
async def handle(self, event: OrderCreatedEvent) -> None:
# Side effects...
self._follow_ups.append(InventoryReservedEvent(order_id=event.order_id))
```
### 4. Event Routing
`EventEmitter` automatically routes events based on their type:
- **DomainEvent** — Processed by event handlers registered in EventMap (in-process). Handlers may return follow-up events via the `events` property; these are processed in the same pipeline (BFS or parallel with semaphore).
- **NotificationEvent** — Sent to message broker (Kafka, RabbitMQ, etc.) for asynchronous processing; no follow-ups.
Single Processing
Each event instance is processed **only once** through EventEmitter. Follow-up events returned by handlers are **new** events that are then processed in the same run (same pipeline) until the queue is empty.
# Event Types
- **Back to Event Handling Overview**
Return to the Event Handling overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md)
______________________________________________________________________
## Overview
| Event Type | Processing | Use Case |
| --------------------- | -------------------------------------- | -------------------------------- |
| **DomainEvent** | Processed by event handlers in-process | Domain logic, read model updates |
| **NotificationEvent** | Sent to message brokers | Cross-service communication |
### DomainEvent
Domain events represent something that happened in the domain. They are processed by event handlers. Handlers can return **follow-up events** via the `events` property; these are processed in the same pipeline (see [Event Flow](https://mkdocs.python-cqrs.dev/latest/event_handler/event_flow/index.md)).
```python
class UserJoined(cqrs.DomainEvent, frozen=True):
user_id: str
meeting_id: str
class UserJoinedEventHandler(cqrs.EventHandler[UserJoined]):
async def handle(self, event: UserJoined) -> None:
# Process domain event
# Optionally populate self._follow_ups and return via events property
...
```
### NotificationEvent
Notification events are sent to message brokers:
```python
class UserJoinedNotification(cqrs.NotificationEvent[UserJoinedPayload]):
event_name: str = "user_joined"
topic: str = "user_events"
payload: UserJoinedPayload
# Automatically sent to message broker via EventEmitter
```
### Event Type Comparison
| Aspect | DomainEvent | NotificationEvent |
| --------------- | ------------------- | --------------------------- |
| **Processing** | In-process handlers | Message broker |
| **Latency** | Synchronous | Asynchronous |
| **Reliability** | Immediate | Requires Outbox pattern |
| **Use Case** | Domain logic | Cross-service communication |
# Complete Examples
- **Back to Event Handling Overview**
Return to the Event Handling overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md)
______________________________________________________________________
## Overview
Here's a complete example demonstrating event handling:
```python
import asyncio
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
# Domain event
class UserJoined(cqrs.DomainEvent, frozen=True):
user_id: str
meeting_id: str
# Command handler
class JoinMeetingCommand(cqrs.Request):
user_id: str
meeting_id: str
class JoinMeetingCommandHandler(cqrs.RequestHandler[JoinMeetingCommand, None]):
def __init__(self):
self._events: list[cqrs.Event] = []
@property
def events(self) -> list[cqrs.Event]:
return self._events
async def handle(self, request: JoinMeetingCommand) -> None:
# Business logic
print(f"User {request.user_id} joined meeting {request.meeting_id}")
# Emit domain event
self._events.append(
UserJoined(user_id=request.user_id, meeting_id=request.meeting_id)
)
# Event handler
class UserJoinedEventHandler(cqrs.EventHandler[UserJoined]):
async def handle(self, event: UserJoined) -> None:
print(f"Processing event: User {event.user_id} joined meeting {event.meeting_id}")
# Side effects: send notification, update read model, etc.
# Mappers
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(JoinMeetingCommand, JoinMeetingCommandHandler)
def domain_events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(UserJoined, UserJoinedEventHandler)
# Bootstrap with parallel processing
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=domain_events_mapper,
max_concurrent_event_handlers=3,
concurrent_event_handle_enable=True,
)
# Execute command
await mediator.send(JoinMeetingCommand(user_id="123", meeting_id="456"))
# Flow:
# 1. Command handler executes
# 2. UserJoined event is collected
# 3. EventProcessor.emit_events runs (EventEmitter finds UserJoinedEventHandler)
# 4. UserJoinedEventHandler.handle() executes
# 5. Response is returned
```
______________________________________________________________________
## Event handler chain (follow-up events)
Event handlers can produce **follow-up events** via the `events` property. These are processed in the same pipeline (BFS in sequential mode, or under the same semaphore in parallel mode), enabling multi-level chains (e.g. L1 → L2 → L3).
```python
import typing
import cqrs
from cqrs.models.event import IEvent
# Level 1: emitted by command handler
class EventL1(cqrs.DomainEvent, frozen=True):
seed: str
# Level 2: emitted by HandlerL1
class EventL2(cqrs.DomainEvent, frozen=True):
seed: str
# Level 3: emitted by HandlerL2 (terminal)
class EventL3(cqrs.DomainEvent, frozen=True):
seed: str
class HandlerL1(cqrs.EventHandler[EventL1]):
def __init__(self) -> None:
self._follow_ups: list[IEvent] = []
@property
def events(self) -> typing.Sequence[IEvent]:
return tuple(self._follow_ups)
async def handle(self, event: EventL1) -> None:
# Side effects...
self._follow_ups.append(EventL2(seed=event.seed))
class HandlerL2(cqrs.EventHandler[EventL2]):
def __init__(self) -> None:
self._follow_ups: list[IEvent] = []
@property
def events(self) -> typing.Sequence[IEvent]:
return tuple(self._follow_ups)
async def handle(self, event: EventL2) -> None:
# Side effects...
self._follow_ups.append(EventL3(seed=event.seed))
class HandlerL3(cqrs.EventHandler[EventL3]):
async def handle(self, event: EventL3) -> None:
# Terminal handler — no follow-ups (default events = ())
pass
# In events_mapper:
def domain_events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(EventL1, HandlerL1)
mapper.bind(EventL2, HandlerL2)
mapper.bind(EventL3, HandlerL3)
```
When you emit `EventL1(seed="x")`, the processor runs: L1 → HandlerL1 emits L2 → HandlerL2 emits L3 → HandlerL3 runs. All in the same `emit_events()` call (sequential BFS or parallel with FIRST_COMPLETED).
# Event Handler Fallback
- **Back to Event Handling Overview**
Return to the Event Handling overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md)
______________________________________________________________________
## Overview
The **Event Handler Fallback** pattern allows you to register an alternative event handler that runs when the primary event handler fails (or when the circuit breaker is open). This provides resilience for side effects such as sending notifications or updating read models when the primary path (e.g. external API) is unavailable.
| Concept | Description |
| ---------------------- | ------------------------------------------------------------------------------------ |
| **Primary handler** | Main event handler that executes first |
| **Fallback handler** | Alternative handler invoked on primary failure or circuit open |
| **failure_exceptions** | Optional tuple of exception types that trigger fallback; if empty, any exception |
| **Circuit Breaker** | Optional; after threshold failures, primary is not called, fallback runs immediately |
When to Use
Use Event Handler Fallback when domain event side effects (notifications, read model updates, integrations) must degrade gracefully: e.g. enqueue for later or log when the primary handler (e.g. external notification API) fails.
## Registration
Bind the event type to `EventHandlerFallback(primary, fallback, ...)` in your domain events mapper:
```python
import cqrs
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(
NotificationSent,
cqrs.EventHandlerFallback(
primary=PrimaryNotificationSentHandler,
fallback=FallbackNotificationSentHandler,
failure_exceptions=(ConnectionError, TimeoutError), # optional
circuit_breaker=event_cb, # optional
),
)
```
- **primary** — The primary event handler class (`EventHandler[EventType]`).
- **fallback** — The fallback event handler class; must handle the same event type.
- **failure_exceptions** — If non-empty, only these exception types trigger fallback; otherwise any exception triggers fallback.
- **circuit_breaker** — Optional `ICircuitBreaker` instance (e.g. `AioBreakerAdapter`). Use one instance per domain (e.g. one for events). When the circuit is open, the primary handler is not called and the fallback runs immediately.
## Basic Example
```python
class NotificationSent(cqrs.DomainEvent, frozen=True):
user_id: str
message: str
class PrimaryNotificationSentHandler(cqrs.EventHandler[NotificationSent]):
async def handle(self, event: NotificationSent) -> None:
# e.g. call external notification API
raise RuntimeError("External notification service unavailable")
class FallbackNotificationSentHandler(cqrs.EventHandler[NotificationSent]):
async def handle(self, event: NotificationSent) -> None:
# e.g. enqueue for later or log
logger.info("Enqueue notification for user %s: %s", event.user_id, event.message)
# In events mapper:
mapper.bind(
NotificationSent,
cqrs.EventHandlerFallback(
primary=PrimaryNotificationSentHandler,
fallback=FallbackNotificationSentHandler,
),
)
```
When a command handler emits `NotificationSent`, the event emitter runs the primary handler first. On exception (or when the circuit is open), the fallback handler is invoked. Events from the handler that actually ran are collected and processed.
| Strategy | Fallback UoW |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------- |
| **SEND** | Same unit of work as the command and the primary event handler. After a DB error the session may need `rollback()` / a savepoint. |
| **HANDLER** | New scope after the primary event handler is rolled back. Primary writes are not visible. |
| **NONE** | No framework scopes. |
This applies both to the event dispatcher and to domain events emitted after `send()` (the event emitter). See [Scope Strategies](https://mkdocs.python-cqrs.dev/latest/scoped_dependencies/strategies/index.md).
## Circuit Breaker (optional)
To use a circuit breaker with event handlers:
```bash
pip install aiobreaker
# or: pip install python-cqrs[aiobreaker]
```
```python
from cqrs.adapters.circuit_breaker import AioBreakerAdapter
event_cb = AioBreakerAdapter(fail_max=5, timeout_duration=60)
mapper.bind(
NotificationSent,
cqrs.EventHandlerFallback(
primary=PrimaryNotificationSentHandler,
fallback=FallbackNotificationSentHandler,
circuit_breaker=event_cb,
),
)
```
After `fail_max` failures, the circuit opens and the fallback runs without calling the primary handler. See [Saga Fallback — Circuit Breaker](https://mkdocs.python-cqrs.dev/latest/saga/fallback/circuit_breaker/index.md) for the three-state pattern (CLOSED / OPEN / HALF_OPEN).
### Circuit Breaker configuration
Use the same `AioBreakerAdapter` as for request handlers. Parameters:
| Parameter | Description | Default |
| ------------------ | ----------------------------------------------------------------------------------- | --------- |
| `fail_max` | Number of failures before opening the circuit | `5` |
| `timeout_duration` | Seconds to wait before attempting HALF_OPEN (retry) | `60` |
| `exclude` | Exception types that **do not** count as failures (e.g. business/validation errors) | `[]` |
| `storage_factory` | Factory `(name: str) -> storage` for circuit state; default is in-memory | in-memory |
**Example with `exclude`** — e.g. invalid payload should not open the circuit:
```python
event_cb = AioBreakerAdapter(
fail_max=5,
timeout_duration=60,
exclude=[ValidationError], # invalid events don't open the circuit
)
mapper.bind(
NotificationSent,
cqrs.EventHandlerFallback(
primary=PrimaryNotificationSentHandler,
fallback=FallbackNotificationSentHandler,
circuit_breaker=event_cb,
),
)
```
**One instance per domain** — use one `AioBreakerAdapter` for all event handler fallbacks that share the same policy. The adapter creates an isolated circuit per handler type.
**Storage:** Default is in-memory. For multiple instances (e.g. several workers), pass a `storage_factory` that returns Redis storage so the circuit state is shared. See [Saga Fallback — Circuit Breaker: Storage Configuration](https://mkdocs.python-cqrs.dev/latest/saga/fallback/circuit_breaker/#storage-configuration-memory-vs-redis).
**Failure filtering:** Use `failure_exceptions` on `EventHandlerFallback` to restrict which exceptions trigger fallback; use `exclude` on `AioBreakerAdapter` so certain exceptions do not open the circuit. See [Saga Fallback — Circuit Breaker](https://mkdocs.python-cqrs.dev/latest/saga/fallback/circuit_breaker/#failure-exception-filtering).
## Related
- [Request Handler Fallback](https://mkdocs.python-cqrs.dev/latest/request_handler/fallback/index.md) — Fallback for command/query handlers
- [Stream Handling Fallback](https://mkdocs.python-cqrs.dev/latest/stream_handling/fallback/index.md) — Fallback for streaming handlers
- [Saga Fallback Pattern](https://mkdocs.python-cqrs.dev/latest/saga/fallback/index.md) — Fallback for saga steps
# Parallel Event Processing
- **Back to Event Handling Overview**
Return to the Event Handling overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md)
______________________________________________________________________
## Overview
Events can be processed in parallel to improve performance. This is controlled by two parameters:
- **`max_concurrent_event_handlers`** — Maximum number of event handlers running simultaneously
- **`concurrent_event_handle_enable`** — Enable/disable parallel processing
### How Parallel Processing Works
In **sequential** mode, events and follow-ups (from `handler.events`) are processed in **BFS order**: one event at a time, then its follow-ups are appended to the queue. In **parallel** mode, events are processed under a semaphore; as soon as any task completes (FIRST_COMPLETED), its follow-up events are queued and started without waiting for sibling events. `emit_events` returns only when all events and follow-ups are done.
```
graph TD
Start[EventProcessor.emit_events] --> CheckEnable{Parallel Enabled?}
CheckEnable -->|No| Sequential[Sequential: BFS]
Sequential --> PopSeq[Pop event from queue]
PopSeq --> EmitSeq[EventEmitter.emit]
EmitSeq --> RouteSeq{Route Event}
RouteSeq -->|DomainEvent| HandlerSeq[Execute Handlers]
RouteSeq -->|NotificationEvent| BrokerSeq[Send to Broker]
HandlerSeq --> CollectSeq[Collect handler.events]
BrokerSeq --> CollectSeq
CollectSeq --> ExtendSeq[Append follow-ups to queue]
ExtendSeq --> MoreSeq{Queue empty?}
MoreSeq -->|No| PopSeq
MoreSeq -->|Yes| End1[End]
CheckEnable -->|Yes| Parallel[Parallel: Semaphore + FIRST_COMPLETED]
Parallel --> PopPar[Start tasks for queued events]
PopPar --> Semaphore[Acquire Semaphore per task]
Semaphore --> EmitPar[EventEmitter.emit]
EmitPar --> RoutePar{Route Event}
RoutePar -->|DomainEvent| HandlerPar[Execute Handlers]
RoutePar -->|NotificationEvent| BrokerPar[Send to Broker]
HandlerPar --> CollectPar[Collect follow-ups]
BrokerPar --> CollectPar
CollectPar --> QueuePar[Queue follow-ups, start new tasks]
QueuePar --> WaitPar[Wait FIRST_COMPLETED]
WaitPar --> MorePar{Pending or queue?}
MorePar -->|Yes| PopPar
MorePar -->|No| End2[End]
style Start fill:#e1f5ff
style Sequential fill:#fff3e0
style Parallel fill:#c8e6c9
style Semaphore fill:#ffebee
```
### Implementation
The `EventProcessor` handles parallel or sequential event emission. Follow-up events returned by handlers (via `handler.events`) are processed in the **same pipeline**: BFS in sequential mode, or under the same semaphore with FIRST_COMPLETED in parallel mode. The method returns when all events and follow-ups are done.
```python
class EventProcessor:
def __init__(
self,
event_map: EventMap,
event_emitter: EventEmitter | None = None,
max_concurrent_event_handlers: int = 1,
concurrent_event_handle_enable: bool = True,
):
self._event_emitter = event_emitter
self._max_concurrent_event_handlers = max_concurrent_event_handlers
self._concurrent_event_handle_enable = concurrent_event_handle_enable
self._event_semaphore = asyncio.Semaphore(max_concurrent_event_handlers)
async def emit_events(self, events: Sequence[IEvent]) -> None:
"""Emit events and process follow-ups in the same pipeline."""
if not events or not self._event_emitter:
return
if not self._concurrent_event_handle_enable:
# Sequential: BFS over events and follow-ups
to_process = deque(events)
while to_process:
event = to_process.popleft()
follow_ups = await self._event_emitter.emit(event)
to_process.extend(follow_ups)
else:
# Parallel: tasks under semaphore; follow-ups queued on FIRST_COMPLETED
await self._emit_events_parallel_first_completed(deque(events))
async def _emit_one_event(self, event: IEvent) -> Sequence[IEvent]:
"""Emit one event under semaphore; returns follow-ups from handler.events."""
async with self._event_semaphore:
return await self._event_emitter.emit(event)
```
The `EventEmitter.emit()` returns follow-up events from domain event handlers; the processor continues with these until the queue is empty.
### Configuration
```python
from cqrs import ScopeStrategy
from cqrs.bootstrap import requests as bootstrap
# Enable parallel processing with max 3 concurrent handlers (HANDLER / NONE only)
mediator = bootstrap.bootstrap(
di_container=container,
commands_mapper=commands_mapper,
domain_events_mapper=domain_events_mapper,
scope_strategy=ScopeStrategy.HANDLER, # or omit for NONE; not SEND
max_concurrent_event_handlers=3, # Max 3 handlers at once
concurrent_event_handle_enable=True, # Enable parallel processing
)
```
SEND is sequential
Do not combine `ScopeStrategy.SEND` with `concurrent_event_handle_enable=True`. Omit the concurrent flag under SEND (`None` → `False`). For parallel events use `HANDLER` or `NONE`.
### Default Values
`concurrent_event_handle_enable` defaults to `None` on `RequestMediator`, `StreamingRequestMediator`, `SagaMediator`, and on `saga.bootstrap` / `setup_mediator` / `setup_streaming_mediator` / `setup_saga_mediator`:
- **`None` + SEND** → `False` (sequential BFS in one UoW). `RequestMediator(..., SEND)`, `saga.bootstrap(..., SEND)`, and `StreamingRequestMediator(..., SEND)` work out of the box.
- **`None` without SEND** → `True` (parallel, FIRST_COMPLETED).
- **Explicit `True` + SEND** → `ValueError` (any `max_concurrent_event_handlers`). Use `HANDLER` or `NONE` for parallel events.
- **`requests.bootstrap`** still defaults concurrent processing to `False`.
Max handlers: **`RequestMediator` / `SagaMediator`** default `max_concurrent_event_handlers=1`; **`StreamingRequestMediator`** defaults to `10`.
### Example: Parallel Processing
```python
# Command handler emits multiple events
class ProcessOrderCommandHandler(RequestHandler[ProcessOrderCommand, None]):
def __init__(self):
self._events: list[Event] = []
@property
def events(self) -> list[Event]:
return self._events
async def handle(self, request: ProcessOrderCommand) -> None:
# Business logic
...
# Emit multiple events
self._events.append(OrderProcessedEvent(...))
self._events.append(InventoryUpdateEvent(...))
self._events.append(AuditLogEvent(...))
self._events.append(EmailNotificationEvent(...))
# With max_concurrent_event_handlers=3:
# - Up to 3 events (or follow-ups) run at once under the semaphore
# - When any task completes, its follow-ups (from handler.events) are queued and started (FIRST_COMPLETED)
# - emit_events() returns only when all events and follow-ups are done
# - Each event is routed by EventEmitter:
# - DomainEvents → processed by handlers (follow-ups collected and processed in same pipeline)
# - NotificationEvents → sent to message broker (no follow-ups)
```
# Runtime Processing
- **Back to Event Handling Overview**
Return to the Event Handling overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md)
______________________________________________________________________
## Overview
**Important:** Events are processed **synchronously** in the same request context, not asynchronously. This means:
- Events are processed **before** the command response is returned
- Event handlers execute **in the same request lifecycle**
- If an event handler fails, it can affect the command response
- Events are **not** queued or processed in background
### Why Runtime Processing?
Runtime processing ensures:
1. **Consistency** — Events are processed immediately, ensuring data consistency
1. **Error Handling** — Errors in event handlers can be caught and handled in the same request
1. **Transaction Safety** — Events can be part of the same transaction as business logic
1. **Predictability** — You know when events are processed (immediately after command)
### Example Flow
```python
# Command execution
await mediator.send(JoinMeetingCommand(user_id="123", meeting_id="456"))
# What happens:
# 1. Command handler executes (synchronously)
# 2. Events are collected from handler.events
# 3. Events are emitted through EventProcessor (synchronously, in parallel if enabled)
# - EventProcessor uses EventEmitter which routes events:
# - DomainEvent → processed by event handlers (in-process)
# - NotificationEvent → sent to message broker
# 4. Response is returned
```
The `EventEmitter` routes events to their handlers or message brokers. For DomainEvents, it uses EventDispatcher logic internally:
```
graph TD
Start[EventEmitter.emit DomainEvent] --> GetType[Get Event Type]
GetType --> Lookup[Lookup in EventMap]
Lookup --> Found{Handlers Found?}
Found -->|No| Warn[Log Warning]
Warn --> End1[End]
Found -->|Yes| LoopStart[For Each Handler Type]
LoopStart --> Resolve[Resolve Handler from DI]
Resolve --> Execute[await handler.handle]
Execute --> NextHandler{More Handlers?}
NextHandler -->|Yes| LoopStart
NextHandler -->|No| End2[End]
style Start fill:#e1f5ff
style Resolve fill:#fff3e0
style Execute fill:#c8e6c9
```
### EventEmitter Implementation for DomainEvents
`EventEmitter.emit()` returns a **sequence of follow-up events** from handlers. The processor uses these to continue the pipeline (BFS in sequential mode, or under the same semaphore in parallel mode).
```python
class EventEmitter:
@emit.register
async def _(self, event: DomainEvent) -> Sequence[IEvent]:
# 1. Find handlers for event type
handlers_types = self._event_map.get(type(event), [])
if not handlers_types:
logger.warning(f"Handlers for event {type(event).__name__} not found")
return ()
follow_ups: list[IEvent] = []
# 2. Process each handler
for handler_type in handlers_types:
# 3. Resolve handler from DI container
handler = await self._container.resolve(handler_type)
# 4. Execute handler
await handler.handle(event)
# 5. Collect follow-up events from handler.events
follow_ups.extend(list(handler.events))
return follow_ups
```
The `EventEmitter` is responsible for emitting events and routing them based on type:
```
graph TD
Start[EventEmitter.emit] --> CheckType{Event Type?}
CheckType -->|DomainEvent| DomainFlow[Domain Event Flow]
CheckType -->|NotificationEvent| NotificationFlow[Notification Event Flow]
DomainFlow --> FindHandlers[Find Handlers in EventMap]
FindHandlers --> ResolveHandler[Resolve Handler]
ResolveHandler --> ExecuteHandler[Execute Handler]
ExecuteHandler --> CollectFollowUps[Collect handler.events]
CollectFollowUps --> ReturnFollowUps[Return follow-ups to Processor]
ReturnFollowUps --> End1[End]
NotificationFlow --> CheckBroker{Broker Available?}
CheckBroker -->|No| Error[Raise RuntimeError]
CheckBroker -->|Yes| CreateMessage[Create Message]
CreateMessage --> SendBroker[Send to Message Broker]
SendBroker --> End2[End]
style Start fill:#e1f5ff
style DomainFlow fill:#c8e6c9
style NotificationFlow fill:#fff3e0
style ExecuteHandler fill:#c8e6c9
style SendBroker fill:#fff3e0
```
### Emitter Implementation
```python
class EventEmitter:
@emit.register
async def _(self, event: DomainEvent) -> Sequence[IEvent]:
# Find handlers for domain event
handlers_types = self._event_map.get(type(event), [])
follow_ups: list[IEvent] = []
for handler_type in handlers_types:
handler = await self._container.resolve(handler_type)
await handler.handle(event)
follow_ups.extend(list(handler.events)) # Follow-ups processed in same pipeline
return follow_ups
@emit.register
async def _(self, event: NotificationEvent) -> Sequence[IEvent]:
await self._send_to_broker(event)
return () # No follow-ups for notification events
```
# Transactional Outbox
The Transactional Outbox pattern ensures reliable event publishing by storing events in a database table within the same transaction as business logic. This guarantees that events are persisted even if the system crashes before they can be published to a message broker. SQLAlchemy and SQLModel are both supported; see [ORM](https://mkdocs.python-cqrs.dev/latest/orm/index.md).
## Overview
- **Implementation**
Interface and SQLAlchemy or SQLModel implementation for transactional outbox.
[Read More](https://mkdocs.python-cqrs.dev/latest/outbox/implementation/index.md)
- **Database Support**
Supported databases, dialect-specific DDL, Alembic setup and custom dialects.
[Read More](https://mkdocs.python-cqrs.dev/latest/outbox/databases/index.md)
- **Usage**
Event registration and publishing with at-least-once delivery guarantees.
[Read More](https://mkdocs.python-cqrs.dev/latest/outbox/usage/index.md)
- **Examples**
Complete examples of transactional outbox pattern.
[Read More](https://mkdocs.python-cqrs.dev/latest/outbox/examples/index.md)
- **Best Practices**
Best practices and recommendations for reliable event delivery.
[Read More](https://mkdocs.python-cqrs.dev/latest/outbox/best_practices/index.md)
The Transactional Outbox pattern solves the problem of ensuring event delivery in distributed systems. When a command handler processes a request and generates events, those events need to be published to a message broker. However, if the system crashes between processing the command and publishing the event, the event can be lost.
The Outbox pattern solves this by:
| Step | Description | Benefit |
| ----------------------------- | ----------------------------------------------------------------------------- | ---------------------- |
| **1. Store in Database** | Events are saved to an outbox table in the same transaction as business logic | Guaranteed persistence |
| **2. Separate Publishing** | A background process reads from the outbox and publishes events | Decoupled publishing |
| **3. At-least-once Delivery** | Events are only removed from outbox after successful publishing | Reliable delivery |
Prerequisites
Understanding of [Event Handling](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md) is required. The Outbox pattern ensures reliable delivery of events that are emitted by command handlers.
Related Topics
- [Event Producing](https://mkdocs.python-cqrs.dev/latest/event_producing/index.md) — For configuring message brokers
- [FastStream Integration](https://mkdocs.python-cqrs.dev/latest/faststream/index.md) — For consuming events from message brokers
- [Bootstrap](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md) — For configuring outbox in bootstrap process
- [Protobuf Integration](https://mkdocs.python-cqrs.dev/latest/protobuf/index.md) — Opt-in Protobuf codecs for outbox storage and publish
## Why Use Transactional Outbox?
### The Problem
Without the Outbox pattern, event publishing can fail:
```
sequenceDiagram
participant Handler as Command Handler
participant DB as Database
participant Broker as Message Broker
Handler->>DB: Process Command & Commit
Handler->>Broker: Publish Event
Note over Handler,Broker: ❌ System Crashes
Event Lost!
```
### The Solution
With the Outbox pattern, events are safely stored:
```
sequenceDiagram
participant Handler as Command Handler
participant DB as Database
participant Outbox as Outbox Table
participant Publisher as Publisher Process
participant Broker as Message Broker
Handler->>DB: Process Command
Handler->>Outbox: Save Event (same transaction)
Handler->>DB: Commit Transaction
Note over Handler,Outbox: ✅ System Crashes
Event Safe in Outbox!
Publisher->>Outbox: Read Events
Publisher->>Broker: Publish Events
Publisher->>Outbox: Update Status
```
## Pattern Flow
The Transactional Outbox pattern follows this flow:
```
sequenceDiagram
participant Handler as Command Handler
participant DB as Database
participant Outbox as Outbox Table
participant Publisher as Event Publisher
participant Broker as Message Broker
Handler->>DB: 1. Process Business Logic
Handler->>Outbox: 2. Save Events (same transaction)
Handler->>DB: 3. Commit Transaction
Note over DB,Outbox: Events safely stored
Publisher->>Outbox: 4. Read Events (batch)
Publisher->>Broker: 5. Publish Events
Publisher->>Outbox: 6. Update Status (PRODUCED)
Publisher->>DB: 7. Commit Transaction
```
### Detailed Flow Diagram
```
graph TD
A[Command Handler] -->|1. Process Command| B[Business Logic]
B -->|2. Generate Events| C[Create Events]
C -->|3. Save to Outbox| D[Outbox Table]
B -->|Same Transaction| D
D -->|4. Commit| E[Transaction Committed]
E -->|Events Stored| F[Event Publisher Process]
F -->|5. Read Batch| D
D -->|6. Return Events| F
F -->|7. Publish| G[Message Broker]
G -->|8. Success| F
F -->|9. Update Status| D
F -->|10. Commit| E
style D fill:#e1f5ff
style E fill:#c8e6c9
style G fill:#fff3e0
```
# Best Practices
- **Back to Transactional Outbox Overview**
Return to the Transactional Outbox overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/outbox/index.md)
______________________________________________________________________
## Overview
1. **Always commit** — Always call `commit()` after adding events (including after `get_many` / publish loops)
1. **Use transactions** — Ensure outbox operations are in the same transaction as business logic
1. **Register events** — Register all event types on an `OutboxedEventMap` (prefer an isolated instance)
1. **Handle failures** — Implement retry logic in the publisher process
1. **Monitor status** — Track `NOT_PRODUCED` events for debugging. Rows that fail to deserialize (or whose `event_name` is not registered) are marked `NOT_PRODUCED` and spend the same `flush_counter` budget as broker publish failures, so they stop filling the selectable batch after the limit
1. **Use compression** — Enable compression for large payloads
1. **Batch processing** — Process events in batches for efficiency
1. **Codecs** — Keep JSON as the default; register Protobuf (or a custom `EventCodec`) only per event that needs it. Changing a codec for an already-registered name does not rewrite pending rows — keep the reader compatible with bytes already stored, or drain the outbox first
1. [**Event Producing**](https://mkdocs.python-cqrs.dev/latest/event_producing/index.md) — How to produce events without outbox
1. [**FastStream Integration**](https://mkdocs.python-cqrs.dev/latest/faststream/index.md) — Kafka and RabbitMQ message broker configuration
1. [**Dependency Injection**](https://mkdocs.python-cqrs.dev/latest/di/index.md) — How to inject outbox repository
1. [**Protobuf Integration**](https://mkdocs.python-cqrs.dev/latest/protobuf/index.md) — Opt-in Protobuf codecs for outbox and brokers
# Database Support
- **Back to Transactional Outbox Overview**
Return to the Transactional Outbox overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/outbox/index.md)
- **Implementation**
Repository interface, SQLAlchemy model and outbox table structure.
[Read More](https://mkdocs.python-cqrs.dev/latest/outbox/implementation/index.md)
- **Usage**
Event registration and publishing with at-least-once delivery guarantees.
[Read More](https://mkdocs.python-cqrs.dev/latest/outbox/usage/index.md)
______________________________________________________________________
## Overview
The SQLAlchemy outbox implementation renders its DDL through *dialect-aware* column types. The same `OutboxModel` produces native types on every supported database: `BINARY(16)` on MySQL, `BYTEA` and `UUID` on PostgreSQL, and a portable `BLOB` fallback everywhere else.
- **Supported out of the box**
MySQL, MariaDB and PostgreSQL are covered by built-in dialect handlers.
[Support Matrix](#support-matrix)
- **Extensible**
Any other dialect works via the portable fallback or a one-line `register_dialect()` call.
[Add Your Database](#adding-your-own-database)
- **Backward compatible**
MySQL DDL is unchanged — existing deployments need no migration.
[Compatibility Notes](#backward-compatibility)
Prerequisites
See [Implementation](https://mkdocs.python-cqrs.dev/latest/outbox/implementation/index.md) for the repository interface and the outbox table structure, and [Usage](https://mkdocs.python-cqrs.dev/latest/outbox/usage/index.md) for event registration and publishing.
## Support Matrix
| Database | Dialect name | Driver (async) | Status |
| --------------------- | ------------ | --------------------- | ----------------------------------------------------------------------------------------------- |
| **MySQL** | `mysql` | `asyncmy`, `aiomysql` | ✅ Built-in, covered by integration tests |
| **MariaDB** | `mariadb` | `asyncmy`, `aiomysql` | ✅ Built-in (same handler as MySQL) |
| **PostgreSQL** | `postgresql` | `asyncpg` | ✅ Built-in, covered by integration tests |
| **SQLite** | `sqlite` | `aiosqlite` | ⚠️ Portable fallback (`BLOB`), useful for tests |
| **SQL Server** | `mssql` | `aioodbc` | ⚠️ Portable fallback, native types via [one registration](#worked-example-microsoft-sql-server) |
| **Oracle and others** | `oracle`, … | driver-specific | ⚠️ Portable fallback, or [register a native type](#adding-your-own-database) |
SQLite and concurrent publishing
SQLite works for unit tests and local experiments, but it does not provide the row-level locking and isolation guarantees the publisher process relies on. Use MySQL/MariaDB or PostgreSQL in production.
## Type Mapping
Three columns go through the dialect-aware layer: `event_id` (the idempotency UUID), `event_id_bin` (its 16-byte binary form used by the unique constraint) and `payload` (the serialized event). They are declared with the public types from `cqrs.sqlalchemy_types` — the same layer that provides `UUIDBinary`, `Binary16`, `PayloadBinary` and the saga storage's `JSONType`:
| Model column | Python type | SQLAlchemy type | PostgreSQL | MySQL / MariaDB | Fallback |
| -------------- | ------------------- | --------------------------- | ----------------------------------------- | --------------------------------------- | -------------------------- |
| `event_id` | `uuid.UUID` | `UUIDBinary` | `UUID` | `BINARY(16)` | `BLOB` (`LargeBinary(16)`) |
| `event_id_bin` | `bytes` | `Binary16` | `BYTEA` | `BINARY(16)` | `BLOB` (`LargeBinary(16)`) |
| `payload` | `bytes` | `PayloadBinary` | `BYTEA` | `BLOB` | `BLOB` (`LargeBinary()`) |
| `event_status` | `EventStatus` | `Enum(EventStatus)` | `eventstatus` (native enum type) | `ENUM('NEW','PRODUCED','NOT_PRODUCED')` | `VARCHAR(12)` |
| `id` | `int` | `BigInteger` + `Identity()` | `BIGINT GENERATED BY DEFAULT AS IDENTITY` | `BIGINT AUTO_INCREMENT` | `BIGINT` |
| `created_at` | `datetime.datetime` | `DateTime` | `TIMESTAMP WITHOUT TIME ZONE` | `DATETIME` | `DATETIME` |
On PostgreSQL `event_id` is stored as a native `UUID`, so `asyncpg` returns `uuid.UUID` values directly. On MySQL/MariaDB the value is converted to its 16 bytes on bind and back to `uuid.UUID` on read — which is exactly the behaviour the outbox had before dialect-aware types were introduced.
Enum values are member *names*
`sqlalchemy.Enum(EventStatus)` persists the **names** of the Python enum members, not their values. The database contains `NEW`, `PRODUCED` and `NOT_PRODUCED` — keep that in mind when writing raw SQL or hand-crafted migrations.
## Ready-to-use DDL
If you create the outbox table manually instead of using `Base.metadata.create_all()` or Alembic, use the statements below. They match what SQLAlchemy renders for each dialect.
### PostgreSQL
```sql
CREATE TYPE eventstatus AS ENUM ('NEW', 'PRODUCED', 'NOT_PRODUCED');
CREATE TABLE outbox (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
event_id UUID NOT NULL,
event_id_bin BYTEA NOT NULL,
event_status eventstatus NOT NULL,
flush_counter SMALLINT NOT NULL,
event_name VARCHAR(255) NOT NULL,
topic VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL,
payload BYTEA NOT NULL,
PRIMARY KEY (id),
CONSTRAINT event_id_unique_index UNIQUE (event_id_bin, event_name)
);
```
### MySQL / MariaDB
```sql
CREATE TABLE outbox (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id BINARY(16) NOT NULL,
event_id_bin BINARY(16) NOT NULL,
event_status ENUM('NEW', 'PRODUCED', 'NOT_PRODUCED') NOT NULL,
flush_counter SMALLINT NOT NULL,
event_name VARCHAR(255) NOT NULL,
topic VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT now(),
payload BLOB NOT NULL,
PRIMARY KEY (id),
CONSTRAINT event_id_unique_index UNIQUE (event_id_bin, event_name)
);
```
Defaults for `flush_counter` and `topic`
Those columns have Python-side defaults (`0` and `''`), so no `DEFAULT` clause is rendered. Add one yourself if other writers insert into the table directly.
## Alembic
### 1. Register the outbox metadata
Add `cqrs.outbox.sqlalchemy.Base.metadata` to `target_metadata` in `alembic/env.py`, next to your own metadata:
```python
import cqrs.outbox.sqlalchemy as cqrs_sqlalchemy
from myapp import orm
target_metadata = [orm.Base.metadata, cqrs_sqlalchemy.Base.metadata]
```
### 2. Make the custom types importable in migrations
Autogenerated migrations reference the dialect-aware types by their fully qualified names, so the migration script must import the module:
```python
op.create_table(
"outbox",
sa.Column("event_id", cqrs.sqlalchemy_types.UUIDBinary(), nullable=False),
sa.Column("event_id_bin", cqrs.sqlalchemy_types.Binary16(), nullable=False),
...
)
```
Add the import once to `alembic/script.py.mako` so every generated revision has it:
```python
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
import cqrs.sqlalchemy_types # <- required by the outbox columns
${imports if imports else ""}
```
Without this import the migration fails
A revision that uses `cqrs.sqlalchemy_types.Binary16()` without importing the module raises `NameError: name 'cqrs' is not defined` when Alembic runs it. This is why the types live at the short public path `cqrs.sqlalchemy_types` rather than inside `cqrs.outbox`.
Alternative: `user_module_prefix`
Instead of editing the template you can set `user_module_prefix` in `context.configure()` and import the module under that prefix in the template. Editing `script.py.mako` is the simpler of the two.
### 3. The `eventstatus` enum type on PostgreSQL
`op.create_table()` emits `CREATE TYPE eventstatus AS ENUM (...)` automatically before creating the table, so a fresh upgrade works as-is. Two caveats:
- **`downgrade()` does not drop the type.** Alembic removes the table but leaves `eventstatus` behind, and the next `upgrade()` then fails with `type "eventstatus" already exists`. Drop it explicitly:
```python
def downgrade() -> None:
op.drop_table("outbox")
sa.Enum(name="eventstatus").drop(op.get_bind(), checkfirst=True)
```
- **If the type already exists** (for example the table was created by `create_all()` earlier), create it with `checkfirst=True` and pass `create_type=False` to the column type in the migration.
### 4. Existing MySQL deployments see no diff
Running `alembic revision --autogenerate` after upgrading `python-cqrs` produces an **empty** migration for the `outbox` table on MySQL, because the rendered DDL is byte-for-byte identical to the previous one.
## Backward Compatibility
| Aspect | Change |
| ------------------- | ----------------------------------------------------------------------------------------- |
| Application code | None — `SqlAlchemyOutboxedEventRepository` and its public API are unchanged |
| Column names | None — `event_id`, `event_id_bin` and the `event_id_unique_index` constraint are kept |
| MySQL / MariaDB DDL | None — `event_id_bin` is still `BINARY(16)` |
| PostgreSQL DDL | `event_id_bin` is now `BYTEA` instead of the invalid `BINARY(16)` |
| `payload` DDL | None — now declared with `PayloadBinary`, which renders the same `BYTEA`/`BLOB` as before |
| Imports | `BinaryUUID` still works as a deprecated alias of `UUIDBinary` |
No migration needed on MySQL
Upgrading the package does not require any schema change on MySQL or MariaDB. A DDL-compilation unit test guards this guarantee.
PostgreSQL users on an older version
Before dialect-aware types, a migration generated from the CQRS metadata failed on PostgreSQL with `type "binary" does not exist`, so there is no legacy PostgreSQL schema to migrate from — just generate the migration again after upgrading.
Switching an application to PostgreSQL is a DSN change only:
```python
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from cqrs import SqlAlchemyOutboxedEventRepository
session_factory = async_sessionmaker(
create_async_engine(
"postgresql+asyncpg://user:password@localhost/database",
isolation_level="REPEATABLE READ",
),
)
outbox = SqlAlchemyOutboxedEventRepository(session=session_factory())
```
## Adding Your Own Database
Out of the box the library knows **MySQL**, **MariaDB** and **PostgreSQL**. Every other dialect — SQL Server, Oracle, SQLite, anything else with a SQLAlchemy driver — still works: the dialect-aware columns fall back to a portable generic type (`LargeBinary`), the table is created and the outbox operates normally.
Registration is optional
You only need `register_dialect()` if the fallback type is not what you want to live with. Nothing breaks without it — you simply get a generic binary column instead of the database's native type.
Reasons to register a handler anyway:
- The fallback renders to a type you consider **legacy or deprecated** on your database.
- You want the column to carry **semantics** the fallback loses (e.g. a real GUID type instead of raw bytes).
- You need a **fixed-width** column for index efficiency instead of a variable-length one.
- Your DBA/schema review simply does not accept the generic type.
### Public API
`cqrs.sqlalchemy_types` exposes the building blocks:
| Name | Purpose |
| ----------------------------------------- | ---------------------------------------------------------------------------------------- |
| `DialectAwareType` | Base `TypeDecorator` that resolves a handler by dialect name |
| `DialectTypeHandler` | How a type lives in one dialect: `type_factory`, optional `bind` and `result` converters |
| `UUIDBinary` | `uuid.UUID` column — used by `event_id` |
| `Binary16` | 16-byte binary column — used by `event_id_bin` |
| `PayloadBinary` | Binary blob of arbitrary length — used by `payload` |
| `JSONType` | JSON document — used by the saga storage `context` column |
| `register_dialect(dialect_name, handler)` | Classmethod registering a handler for one dialect |
| `get_handler(dialect_name)` | Classmethod returning the handler in effect (or the fallback) |
`JSONType` belongs to the saga storage
The type layer is shared by the whole library, not just the outbox. `JSONType` renders as plain `JSON` on every dialect and exists so that a project can opt into PostgreSQL `JSONB` with one registration — see [Saga Storage](https://mkdocs.python-cqrs.dev/latest/saga/storage/#optional-jsonb-on-postgresql). Everything below about handlers, placement and registration order applies to it unchanged.
Registrations are per type
Each subclass keeps its own handler registry, so registering a handler for `Binary16` does not affect `UUIDBinary` (or any custom `DialectAwareType` of your own). Register for every type you care about.
### Worked Example: Microsoft SQL Server
SQL Server is a good illustration: the fallback *works*, but the result is not what you would design by hand.
#### What the fallback gives you
Compiled from the shipped model against the default `mssql` (pyodbc) dialect, before registering anything:
```sql
CREATE TABLE outbox (
id BIGINT NOT NULL IDENTITY,
event_id VARBINARY(16) NOT NULL,
event_id_bin VARBINARY(16) NOT NULL,
event_status VARCHAR(12) NOT NULL,
flush_counter SMALLINT NOT NULL,
event_name VARCHAR(255) NOT NULL,
topic VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
payload VARBINARY(max) NOT NULL,
PRIMARY KEY (id),
CONSTRAINT event_id_unique_index UNIQUE (event_id_bin, event_name)
);
```
That is already a usable schema — note that `payload` lands on `VARBINARY(max)` with no registration at all. Two things are still worth fixing:
1. **`event_id` loses its meaning.** It is a UUID, but the column is raw binary. SQL Server has a first-class `UNIQUEIDENTIFIER` type; without it you cannot compare or join the column against GUID columns, and every report or ad-hoc query shows a hex blob.
1. **`VARBINARY` is variable-length** for a value that is always exactly 16 bytes. `BINARY(16)` is fixed-width and a better key for `event_id_unique_index`.
Why `VARBINARY` and not the deprecated `IMAGE`
Going through the type layer is what earns this. `DialectAwareType.load_dialect_impl()` resolves the fallback via `dialect.type_descriptor(...)`, which gives the driver's `colspecs` a chance to map `LargeBinary` to `_VARBINARY_pyodbc`. A plain `sqlalchemy.LargeBinary` column skips that step during DDL compilation and renders as `IMAGE`, which Microsoft has deprecated.
The mapping lives in the driver, so it is driver-dependent: `mssql+pyodbc` and `mssql+aioodbc` both give `VARBINARY`, while a driverless `mssql` dialect has no such `colspec` and still renders `IMAGE` — for `event_id`, `event_id_bin` and `payload` alike. A registration pins the type regardless of driver.
#### Step 1 — Install an async driver and pick the DSN
The outbox repository works on an `AsyncSession`, so the driver has to be an async one:
```bash
pip install aioodbc # plus the system "ODBC Driver 18 for SQL Server"
```
```python
create_async_engine(
"mssql+aioodbc://user:password@localhost:1433/database"
"?driver=ODBC+Driver+18+for+SQL+Server",
isolation_level="REPEATABLE READ",
)
```
Register by dialect name, not by driver
Handlers are keyed by `dialect.name`, which is `mssql` for both `mssql+pyodbc` and `mssql+aioodbc` (and `oracle` for `oracle+oracledb` and `oracle+oracledb_async`). One registration covers every driver of that database.
#### Step 2 — Put the registrations in one importable module
Create a module that does nothing but register types, for example `myapp/db_types.py`:
```python
from sqlalchemy.dialects import mssql
from cqrs.sqlalchemy_types import Binary16, DialectTypeHandler, PayloadBinary, UUIDBinary
# event_id: a real GUID column instead of raw bytes.
UUIDBinary.register_dialect(
"mssql",
DialectTypeHandler(type_factory=lambda dialect: mssql.UNIQUEIDENTIFIER(as_uuid=True)),
)
# event_id_bin: always exactly 16 bytes, so fixed-width BINARY beats VARBINARY.
Binary16.register_dialect(
"mssql",
DialectTypeHandler(type_factory=lambda dialect: mssql.BINARY(16)),
)
# payload: optional — the driver already maps it to VARBINARY(max); this pins it
# so the type does not depend on which ODBC driver compiles the DDL.
PayloadBinary.register_dialect(
"mssql",
DialectTypeHandler(type_factory=lambda dialect: mssql.VARBINARY("max")),
)
```
That is the whole mapping — no `bind`/`result` converters are needed here, because SQLAlchemy's own `UNIQUEIDENTIFIER(as_uuid=True)` already converts between `uuid.UUID` and the GUID representation the driver expects, while `BINARY(16)` and `VARBINARY(max)` take the `bytes` the repository passes as-is. With those handlers in place the model compiles to:
```sql
CREATE TABLE outbox (
id BIGINT NOT NULL IDENTITY,
event_id UNIQUEIDENTIFIER NOT NULL,
event_id_bin BINARY(16) NOT NULL,
event_status VARCHAR(12) NOT NULL,
flush_counter SMALLINT NOT NULL,
event_name VARCHAR(255) NOT NULL,
topic VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
payload VARBINARY(max) NOT NULL,
PRIMARY KEY (id),
CONSTRAINT event_id_unique_index UNIQUE (event_id_bin, event_name)
);
```
#### Step 3 — Import that module in **both** entry points
The registrations only exist in the process that imported them, so import the module in every process that touches the outbox table:
```python
# myapp/main.py — application entry point
import myapp.db_types # noqa: F401 registers the mssql handlers
from myapp import api
... # create_async_engine(), bootstrap(), etc.
```
```python
# alembic/env.py — migration process
import myapp.db_types # noqa: F401 must be imported before autogenerate runs
import cqrs.outbox.sqlalchemy as cqrs_sqlalchemy
from myapp import orm
target_metadata = [orm.Base.metadata, cqrs_sqlalchemy.Base.metadata]
```
Forgetting `alembic/env.py` is the classic mistake
If only the application registers the handler, migrations are generated from the *fallback* types while the running application expects the native ones. The schema and the runtime then disagree — the migration creates `VARBINARY(16)`, the app binds values as `UNIQUEIDENTIFIER`.
#### Step 4 — Register before the first use, not later
Put the import at the very top of the entry point, before the engine is created and before the first query or `create_all()`. SQLAlchemy memoizes bind/result processors per dialect instance, so a registration that happens after the engine has already processed a value is silently ignored by that engine:
| When you register | DDL compilation | Value conversion |
| ---------------------------------- | --------------- | --------------------------------- |
| Before the engine is used | native type | native converters |
| After the engine processed a value | native type | **still the fallback converters** |
The second row is the dangerous one: the table would be created with `UNIQUEIDENTIFIER`, but the already-cached fallback processor keeps returning the raw GUID string instead of a `uuid.UUID`.
#### Step 5 — Add `bind`/`result` when the native type needs conversion
A handler has three parts:
- **`type_factory(dialect)`** — required. Returns the `TypeEngine` used for DDL and for binding.
- **`bind(value)`** — optional. Converts the Python value before it goes to the database. Omit it to pass the value through unchanged.
- **`result(value)`** — optional. Converts the value coming back from the database. Omit it to pass it through.
Whether you need converters depends entirely on the native type you picked:
| Native type for `event_id` | Converters | Why |
| -------------------------------------- | ----------------- | --------------------------------------------------- |
| `mssql.UNIQUEIDENTIFIER(as_uuid=True)` | none | The SQLAlchemy type already maps `uuid.UUID` ⇄ GUID |
| `oracle.RAW(16)` | `bind` + `result` | A raw byte column knows nothing about UUIDs |
For a byte-oriented native type you must spell the conversion out, otherwise the driver receives a `uuid.UUID` it cannot bind and reads back `bytes` where the repository expects `uuid.UUID`:
```python
UUIDBinary.register_dialect(
"oracle",
DialectTypeHandler(
type_factory=lambda dialect: oracle.RAW(16),
bind=lambda value: value.bytes,
result=lambda value: uuid.UUID(bytes=value),
),
)
```
`None` never reaches your converters
`DialectAwareType` short-circuits `NULL`, so `bind`/`result` are only called with non-`None` values.
#### Step 6 — Verify the mapping without a database
Before provisioning anything, compile the model's DDL against your dialect. This catches a missing or misplaced registration in a second:
```python
import myapp.db_types # noqa: F401 the registrations under test
from sqlalchemy.dialects import mssql
from sqlalchemy.schema import CreateTable
from cqrs.outbox.sqlalchemy import OutboxModel
print(CreateTable(OutboxModel.__table__).compile(dialect=mssql.dialect()))
```
Make it a test so a lost import cannot slip through review:
```python
def test_outbox_uses_native_mssql_types() -> None:
ddl = str(CreateTable(OutboxModel.__table__).compile(dialect=mssql.dialect()))
assert "event_id UNIQUEIDENTIFIER" in ddl
assert "event_id_bin BINARY(16)" in ddl
assert "VARBINARY(16)" not in ddl # the fallback is gone
```
#### Step 7 — Check the database-specific pitfalls
Two parts of the model are portable in SQLAlchemy but constrained by the database itself:
- **The unique constraint.** `event_id_unique_index` spans `event_id_bin` plus `event_name VARCHAR(255)`, and index key size limits are per-database: SQL Server caps a non-clustered key at 1700 bytes, MySQL's InnoDB at 767 bytes per column unless `DYNAMIC` row format is used (255 characters of `utf8mb4` is 1020 bytes, which is why this can bite), and Oracle derives its limit from the block size. If your collation makes the key too wide, shorten `event_name` in your own migration rather than dropping the constraint — the outbox relies on it for idempotency.
- **`Identity()` on the primary key.** It renders as `IDENTITY` on SQL Server and `GENERATED BY DEFAULT AS IDENTITY` on Oracle 12c and newer, but older Oracle releases have no identity columns at all and need a sequence plus trigger, and SQLite ignores it (a `BIGINT` primary key is not auto-incrementing there — only `INTEGER PRIMARY KEY` is). On such databases provide the key generation yourself in the migration.
Also keep in mind that `sqlalchemy.Enum(EventStatus)` only becomes a native enum on MySQL and PostgreSQL; everywhere else it is a plain 12-character string column holding `NEW`, `PRODUCED` or `NOT_PRODUCED`.
### Compact Example: Oracle
The same recipe on Oracle is four lines — `RAW(16)` for both columns, with converters on `event_id` because `RAW` is a plain byte type:
```python
import uuid
from sqlalchemy.dialects import oracle
from cqrs.sqlalchemy_types import Binary16, DialectTypeHandler, UUIDBinary
UUIDBinary.register_dialect(
"oracle",
DialectTypeHandler(
type_factory=lambda dialect: oracle.RAW(16),
bind=lambda value: value.bytes,
result=lambda value: uuid.UUID(bytes=value),
),
)
Binary16.register_dialect(
"oracle",
DialectTypeHandler(type_factory=lambda dialect: oracle.RAW(16)),
)
```
With that module imported, the model compiles to `event_id RAW(16)` and `event_id_bin RAW(16)` instead of the `BLOB` columns the fallback would produce. Everything else — where to import it, the `alembic/env.py` requirement, the DDL self-check — is exactly as in the SQL Server walkthrough above.
Related Topics
- [Implementation](https://mkdocs.python-cqrs.dev/latest/outbox/implementation/index.md) — Repository interface and table structure
- [Best Practices](https://mkdocs.python-cqrs.dev/latest/outbox/best_practices/index.md) — Recommendations for reliable event delivery
- [Saga Storage](https://mkdocs.python-cqrs.dev/latest/saga/storage/index.md) — Persistence for the Saga pattern
# Examples
- **Back to Transactional Outbox Overview**
Return to the Transactional Outbox overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/outbox/index.md)
______________________________________________________________________
## Overview
Here's a complete example showing the outbox pattern:
```python
import asyncio
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
# Register events
class UserJoinedPayload(cqrs.BaseModel, frozen=True):
user_id: str
meeting_id: str
cqrs.OutboxedEventMap.register(
"user_joined",
cqrs.NotificationEvent[UserJoinedPayload],
)
# Command handler
class JoinMeetingCommand(cqrs.Request):
user_id: str
meeting_id: str
class JoinMeetingCommandHandler(cqrs.RequestHandler[JoinMeetingCommand, None]):
def __init__(self, outbox: cqrs.OutboxedEventRepository):
self.outbox = outbox
@property
def events(self) -> list[cqrs.Event]:
return []
async def handle(self, request: JoinMeetingCommand) -> None:
# Business logic
print(f"User {request.user_id} joined meeting {request.meeting_id}")
# Save event to outbox
self.outbox.add(
cqrs.NotificationEvent[UserJoinedPayload](
event_name="user_joined",
topic="user_events",
payload=UserJoinedPayload(
user_id=request.user_id,
meeting_id=request.meeting_id,
),
)
)
# Commit transaction
await self.outbox.commit()
# Setup DI
def setup_di():
container = di.Container()
session_factory = async_sessionmaker(
create_async_engine("mysql+asyncmy://user:pass@localhost/db")
)
container.bind(
di.bind_by_type(
di.Dependent(
lambda: cqrs.SqlAlchemyOutboxedEventRepository(
session=session_factory(),
),
scope="request",
),
cqrs.OutboxedEventRepository,
)
)
return container
# Bootstrap
mediator = bootstrap.bootstrap(
di_container=setup_di(),
commands_mapper=lambda m: m.bind(JoinMeetingCommand, JoinMeetingCommandHandler),
)
# Use mediator
await mediator.send(JoinMeetingCommand(user_id="123", meeting_id="456"))
```
## Protobuf outbox (opt-in)
JSON remains the default codec. To store and publish selected events as Protobuf, register a `ProtobufEventSerializer` on an isolated `OutboxedEventMap` and pass that map into the repository.
Runnable produce + consume (no Kafka):
```bash
pip install -e ".[examples]"
python examples/outbox/protobuf_outbox.py
```
Full walkthrough: [Protobuf Integration](https://mkdocs.python-cqrs.dev/latest/protobuf/index.md). Source: [protobuf_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/outbox/protobuf_outbox.py).
# Implementation
- **Back to Transactional Outbox Overview**
Return to the Transactional Outbox overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/outbox/index.md)
______________________________________________________________________
## Overview
The `OutboxedEventRepository` interface defines the contract for outbox implementations:
```python
from abc import ABC
from typing import Generic, List, Text
import cqrs
from cqrs.outbox.repository import EventStatus, OutboxedEvent
class OutboxedEventRepository(ABC, Generic[Session]):
"""Abstract interface for outbox event repositories."""
def add(self, event: cqrs.NotificationEvent) -> None:
"""Add an event to the outbox repository."""
pass
async def get_many(
self,
batch_size: int = 100,
topic: Text | None = None,
) -> List[OutboxedEvent]:
"""Get many events from the repository."""
pass
async def update_status(
self,
outboxed_event_id: int,
new_status: EventStatus,
) -> None:
"""Update the event status."""
pass
async def commit(self) -> None:
"""Commit the transaction."""
pass
async def rollback(self) -> None:
"""Rollback the transaction."""
pass
```
### Methods
#### `add(event)`
Adds an event to the outbox. The event is stored but not yet committed. Must be called within a transaction context.
**Parameters:**
- `event` — `cqrs.NotificationEvent` to store
**Example:**
```python
outbox.add(
cqrs.NotificationEvent[UserJoinedPayload](
event_name="user_joined",
topic="user_events",
payload=UserJoinedPayload(user_id="123", meeting_id="456"),
)
)
```
#### `get_many(batch_size, topic)`
Retrieves events from the outbox in batches. Used by the publisher process.
**Parameters:**
- `batch_size` — Maximum number of events to retrieve (default: 100)
- `topic` — Optional topic filter to retrieve events for specific topic
**Returns:**
- `List[OutboxedEvent]` — List of outboxed events
**Example:**
```python
events = await outbox.get_many(batch_size=50, topic="user_events")
```
#### `update_status(outboxed_event_id, new_status)`
Updates the status of an event in the outbox. Used to mark events as produced or failed.
**Parameters:**
- `outboxed_event_id` — ID of the event in outbox
- `new_status` — New status (`EventStatus.NEW`, `EventStatus.PRODUCED`, `EventStatus.NOT_PRODUCED`)
**Example:**
```python
await outbox.update_status(event_id=1, new_status=EventStatus.PRODUCED)
```
#### `commit()`
Commits the current transaction. All events added via `add()` are persisted.
**Example:**
```python
outbox.add(event1)
outbox.add(event2)
await outbox.commit() # Events are now persisted
```
#### `rollback()`
Rolls back the current transaction. All events added via `add()` are discarded.
**Example:**
```python
try:
outbox.add(event1)
await outbox.commit()
except Exception:
await outbox.rollback() # Event is discarded
```
Events in the outbox have three possible statuses:
```python
class EventStatus(StrEnum):
NEW = "new" # Event is new and ready to be published
PRODUCED = "produced" # Event has been successfully published
NOT_PRODUCED = "not_produced" # Event publishing failed
```
Stored values are the member names
The column is mapped with `sqlalchemy.Enum(EventStatus)`, which persists the **names** of the enum members. In the database you will see `NEW`, `PRODUCED` and `NOT_PRODUCED` — not the lowercase Python values. This matters when you query the table with raw SQL or write migrations by hand.
The `python-cqrs` package includes a SQLAlchemy implementation of the outbox pattern. From **5.1** the same repository behavior is also available with SQLModel. The table, dialect-aware column types, batch query, flush budget, and status ordering are the same; pick one ORM per process. See [ORM](https://mkdocs.python-cqrs.dev/latest/orm/index.md).
### Database Schema
The outbox model is built from dialect-aware column types, so the rendered DDL differs between databases. The two supported variants are:
**MySQL / MariaDB:**
```sql
CREATE TABLE outbox (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id BINARY(16) NOT NULL,
event_id_bin BINARY(16) NOT NULL,
event_status ENUM('NEW', 'PRODUCED', 'NOT_PRODUCED') NOT NULL,
flush_counter SMALLINT NOT NULL,
event_name VARCHAR(255) NOT NULL,
topic VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT now(),
payload BLOB NOT NULL,
PRIMARY KEY (id),
CONSTRAINT event_id_unique_index UNIQUE (event_id_bin, event_name)
);
```
**PostgreSQL:**
```sql
CREATE TYPE eventstatus AS ENUM ('NEW', 'PRODUCED', 'NOT_PRODUCED');
CREATE TABLE outbox (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
event_id UUID NOT NULL,
event_id_bin BYTEA NOT NULL,
event_status eventstatus NOT NULL,
flush_counter SMALLINT NOT NULL,
event_name VARCHAR(255) NOT NULL,
topic VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL,
payload BYTEA NOT NULL,
PRIMARY KEY (id),
CONSTRAINT event_id_unique_index UNIQUE (event_id_bin, event_name)
);
```
Other databases and Alembic
See [Database Support](https://mkdocs.python-cqrs.dev/latest/outbox/databases/index.md) for the full support matrix, the type mapping per dialect, the Alembic recipe and how to register a native type for a database the library does not know about.
### Features
- **UUID Support** — Events have unique IDs for idempotency
- **Binary Storage** — Efficient storage of UUIDs as binary
- **Portable Schema** — Native column types per dialect (MySQL, MariaDB, PostgreSQL) with a portable fallback
- **Status Tracking** — Tracks event publishing status
- **Compression Support** — Optional payload compression
- **Batch Processing** — Optimized queries for batch retrieval
### Usage
```python
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from cqrs import SqlAlchemyOutboxedEventRepository
from cqrs.compressors import ZlibCompressor
# Create session factory (MySQL)
session_factory = async_sessionmaker(
create_async_engine(
"mysql+asyncmy://user:password@localhost/database",
isolation_level="REPEATABLE READ",
)
)
# ...or PostgreSQL — only the DSN changes
session_factory = async_sessionmaker(
create_async_engine(
"postgresql+asyncpg://user:password@localhost/database",
isolation_level="REPEATABLE READ",
)
)
# Create repository with optional compression
outbox = SqlAlchemyOutboxedEventRepository(
session=session_factory(),
compressor=ZlibCompressor(), # Optional
)
# Use in command handler
class MyCommandHandler(RequestHandler[MyCommand, None]):
def __init__(self, outbox: OutboxedEventRepository):
self.outbox = outbox
async def handle(self, request: MyCommand) -> None:
# Business logic
...
# Save events to outbox
self.outbox.add(
cqrs.NotificationEvent[MyPayload](
event_name="my_event",
topic="my_topic",
payload=MyPayload(...),
)
)
# Commit transaction
await self.outbox.commit()
```
### Configuration
#### Table Name
By default, the table name is `outbox`. To rename it — or to attach the outbox model to your own declarative base so that it shares one `MetaData` with the rest of your schema — use `rebind_outbox_model`:
```python
import cqrs
from cqrs.outbox.sqlalchemy import OutboxModel
from myapp.orm import Base # your own declarative base
cqrs.rebind_outbox_model(OutboxModel, Base, table_name="my_outbox_table")
```
Call it before the table is used
`rebind_outbox_model` mutates the model and re-registers its table in the target metadata, so call it at import/startup time — before `create_all()`, before generating migrations and before the first query.
#### Compression
Compression is optional but recommended for large payloads:
```python
from cqrs.compressors import ZlibCompressor
outbox = SqlAlchemyOutboxedEventRepository(
session=session,
compressor=ZlibCompressor(),
)
```
# Usage
- **Back to Transactional Outbox Overview**
Return to the Transactional Outbox overview page with all topics.
[Back to Overview](https://mkdocs.python-cqrs.dev/latest/outbox/index.md)
______________________________________________________________________
## Overview
Events must be registered in `OutboxedEventMap` before they can be stored.
Prefer an **isolated** map instance. Class-level `OutboxedEventMap.register(...)` still works but mutates a process-global singleton.
```python
import cqrs
from pydantic import BaseModel
class UserJoinedPayload(BaseModel, frozen=True):
user_id: str
meeting_id: str
events = cqrs.OutboxedEventMap()
events.register(
"user_joined",
cqrs.NotificationEvent[UserJoinedPayload],
)
repository = cqrs.SqlAlchemyOutboxedEventRepository(
session,
event_map=events,
)
```
This registration is required for:
- Type safety when storing events
- Deserialization when reading events
- Validation of event structure
Optional `serializer=` on `register` (or the repository `serializer=` fallback) controls how payload bytes are stored. JSON is the default; Protobuf is opt-in — see [Protobuf Integration](https://mkdocs.python-cqrs.dev/latest/protobuf/index.md).
Events are published by a separate process using `EventProducer`:
```python
import asyncio
import cqrs
from cqrs.message_brokers import kafka
from cqrs.adapters import kafka as kafka_adapters
# Create message broker
broker = kafka.KafkaMessageBroker(
producer=kafka_adapters.kafka_producer_factory(dsn="localhost:9092"),
)
# Create event producer
producer = cqrs.EventProducer(
message_broker=broker,
repository=outbox_repository,
)
# Publish events in batches
async def publish_events():
async for events in producer.event_batch_generator():
for event in events:
await producer.send_message(event)
await producer.repository.commit()
await asyncio.sleep(10) # Poll interval
asyncio.run(publish_events())
```
# ORM
The transactional outbox and saga storage can use **SQLAlchemy** or **SQLModel**. Both extras cover the same operations and render the same DDL, including the dialect-aware column types in [Database support](https://mkdocs.python-cqrs.dev/latest/outbox/databases/index.md).
Use one ORM for these tables in a process.
- **SQLAlchemy**
`SqlAlchemyOutboxedEventRepository` and `SqlAlchemySagaStorage`.
[SQLAlchemy](#sqlalchemy)
- **SQLModel**
Same outbox and saga storage, implemented as SQLModel models. Available since 5.1.
[SQLModel](#sqlmodel)
- **Transactional Outbox**
Batch reads, flush budget, codecs, and `rebind_outbox_model`.
[Implementation](https://mkdocs.python-cqrs.dev/latest/outbox/implementation/index.md)
- **Saga storage**
Checkpoint runs, optimistic locking, and recovery queries.
[Saga storage](https://mkdocs.python-cqrs.dev/latest/saga/storage/index.md)
## Install
```bash
pip install "python-cqrs[sqlalchemy]" # SqlAlchemyOutboxedEventRepository, SqlAlchemySagaStorage
pip install "python-cqrs[sqlmodel]" # SqlModelOutboxedEventRepository, SqlModelSagaStorage
```
`sqlmodel` pulls in SQLAlchemy. The SQLModel classes are models over that same schema: batch reads, flush budget, status ordering, codecs, compression, checkpoint runs, optimistic locking, and recovery queries behave the same way.
| | SQLAlchemy | SQLModel |
| ------- | ---------------------------------------------------- | ------------------------------------------------------ |
| Extra | `python-cqrs[sqlalchemy]` | `python-cqrs[sqlmodel]` |
| Outbox | `cqrs.SqlAlchemyOutboxedEventRepository` | `cqrs.outbox.sqlmodel.SqlModelOutboxedEventRepository` |
| Saga | `cqrs.saga.storage.sqlalchemy.SqlAlchemySagaStorage` | `cqrs.saga.storage.sqlmodel.SqlModelSagaStorage` |
| Session | `sqlalchemy.ext.asyncio.AsyncSession` | `sqlmodel.ext.asyncio.session.AsyncSession` |
| Since | 5.0 (optional extra) | 5.1 |
## SQLAlchemy
Create the tables from `Base.metadata` and pass a SQLAlchemy async session to the outbox repository. Saga storage takes an `async_sessionmaker`, not a single session.
```python
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from cqrs import SqlAlchemyOutboxedEventRepository
from cqrs.outbox.sqlalchemy import Base as OutboxBase
from cqrs.saga.storage.sqlalchemy import Base as SagaBase
from cqrs.saga.storage.sqlalchemy import SqlAlchemySagaStorage
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as connection:
await connection.run_sync(OutboxBase.metadata.create_all)
await connection.run_sync(SagaBase.metadata.create_all)
async with session_factory() as session:
repository = SqlAlchemyOutboxedEventRepository(session)
repository.add(event)
await repository.commit()
storage = SqlAlchemySagaStorage(session_factory)
```
Outbox details, including compression, codecs, and `rebind_outbox_model`, are in [Implementation](https://mkdocs.python-cqrs.dev/latest/outbox/implementation/index.md). Saga checkpoints, optimistic locking, and recovery are in [Saga storage](https://mkdocs.python-cqrs.dev/latest/saga/storage/#sqlalchemy-storage).
## SQLModel
The SQLModel models live on their own metadata (`Base` in `cqrs.outbox.sqlmodel` and `cqrs.saga.storage.sqlmodel` is the same base), not on your application's `SQLModel.metadata`.
### Outbox
```python
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel.ext.asyncio.session import AsyncSession
from cqrs.outbox.sqlmodel import Base, SqlModelOutboxedEventRepository
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
async with AsyncSession(engine) as session:
repository = SqlModelOutboxedEventRepository(session)
repository.add(event)
await repository.commit()
batch = await repository.get_many(batch_size=100)
```
`SqlModelOutboxedEventRepository` accepts the same `compressor`, `serializer`, and `event_map` arguments as `SqlAlchemyOutboxedEventRepository`.
To attach the outbox table to your own SQLModel base (for example so Alembic sees it next to application tables), call `rebind_outbox_model` from `cqrs.outbox.sqlmodel`. On PostgreSQL the unique constraint name `event_id_unique_index` must stay unique in the schema, so drop or rename the previous outbox table before creating the rebound one.
### Saga storage
Pass an `async_sessionmaker` whose class is SQLModel's async session:
```python
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlmodel.ext.asyncio.session import AsyncSession
from cqrs.saga.storage.sqlmodel import Base, SqlModelSagaStorage
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
storage = SqlModelSagaStorage(session_factory)
```
`SqlModelSagaStorage` implements the full saga storage interface, including `create_run()` for checkpoint commits and `load_saga_state(..., read_for_update=True)`. Hand it to `SagaMediator` the same way as `SqlAlchemySagaStorage`.
Table names follow the SQLAlchemy storage: `saga_executions` and `saga_logs`, or the `CQRS_SAGA_EXECUTION_TABLE_NAME` and `CQRS_SAGA_LOG_TABLE_NAME` environment variables read when the SQLAlchemy storage module is imported.
## Schema
MySQL, MariaDB, and PostgreSQL are the supported production databases. The SQLModel models compile to the same `CREATE TABLE` statements as the SQLAlchemy models, so an existing SQLAlchemy deployment does not need a migration to be read by the SQLModel repository, and the other way around. See [Database support](https://mkdocs.python-cqrs.dev/latest/outbox/databases/index.md) for dialect types, Alembic, and `register_dialect()`.
# Mermaid Diagram Generation
The `python-cqrs` package includes built-in support for generating Mermaid diagrams from various components. This feature is perfect for documentation, visualization, and understanding component structure and execution flow.
## Overview
Mermaid diagram generation is available for:
1. **Chain of Responsibility** - Generate Sequence and Class diagrams for handler chains
1. **Saga Pattern** - Generate Sequence and Class diagrams for saga execution flows
## Available Generators
### Chain of Responsibility
The `CoRMermaid` class generates diagrams for Chain of Responsibility handler chains:
- **Sequence Diagram** - Shows the execution flow through the chain, successful handling, and pass-through scenarios
- **Class Diagram** - Shows the type structure, relationships between handlers, request types, response types, and chain links
[**Learn more about Chain of Responsibility Mermaid →**](https://mkdocs.python-cqrs.dev/latest/mermaid/chain_of_responsibility/index.md)
### Saga Pattern
The `SagaMermaid` class generates diagrams for Saga instances:
- **Sequence Diagram** - Shows the execution flow, success/failure scenarios, and compensation logic
- **Class Diagram** - Shows the type structure, relationships between Saga, steps, contexts, responses, and events
[**Learn more about Saga Mermaid →**](https://mkdocs.python-cqrs.dev/latest/mermaid/saga/index.md)
## Usage in Documentation
Generated diagrams can be:
- **Copied and pasted** into [Mermaid Live Editor](https://mermaid.live/) for visualization
- **Embedded directly** in Markdown files (GitHub/GitLab support Mermaid)
- **Used in documentation tools** (Confluence, Notion, etc.)
- **Included in README files** for better understanding
## See Also
- [Chain of Responsibility Mermaid Diagrams](https://mkdocs.python-cqrs.dev/latest/mermaid/chain_of_responsibility/index.md) - Detailed guide for CoR diagrams
- [Saga Mermaid Diagrams](https://mkdocs.python-cqrs.dev/latest/mermaid/saga/index.md) - Detailed guide for Saga diagrams
- [Chain of Responsibility Overview](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/index.md) - Learn about the Chain of Responsibility pattern
- [Saga Pattern Overview](https://mkdocs.python-cqrs.dev/latest/saga/index.md) - Learn about the Saga pattern
# Mermaid Diagram Generation for Chain of Responsibility
The package includes built-in support for generating Mermaid diagrams from Chain of Responsibility handler chains. This feature is perfect for documentation, visualization, and understanding handler chain structure and execution flow.
## Overview
The `CoRMermaid` class can generate two types of diagrams:
1. **Sequence Diagram** - Shows the execution flow through the chain, successful handling, and pass-through scenarios
1. **Class Diagram** - Shows the type structure, relationships between handlers, request types, response types, and chain links
## Example
You can find a complete working example in the repository:
[**View Example: `cor_mermaid.py`**](https://github.com/vadikko2/python-cqrs/blob/master/examples/cor/cor_mermaid.py)
### Basic Usage
```python
from cqrs.mermaid.cor import CoRMermaid
from cqrs.handlers.cor import CORRequestHandler
# Create your handler chain (see Chain of Responsibility documentation for details)
handlers = [
CreditCardHandler,
PayPalHandler,
BankTransferHandler,
DefaultPaymentHandler,
]
# Create Mermaid generator
generator = CoRMermaid(handlers)
# Generate Sequence diagram showing execution flow
sequence_diagram = generator.sequence()
print(sequence_diagram)
# Generate Class diagram showing type structure
class_diagram = generator.class_diagram()
print(class_diagram)
```
## Sequence Diagram
The Sequence diagram visualizes the complete execution flow of a handler chain using nested `alt/else` blocks:
- Each handler can either **process the request** (return result and stop the chain) or **pass to the next handler**
- The diagram shows all possible paths through the chain using nested conditional blocks
- **Any handler can process the request and stop the chain**, not just the last one
- Handler names (not aliases) are used in `alt` conditions for better readability
- **After a handler successfully processes a request**, events are collected from the handler's `events` property and processed by the EventMediator
### Example Handler Chain Code
```python
from cqrs.handlers.cor import CORRequestHandler
from cqrs.models.response import Response
class ProcessPaymentCommand(cqrs.Request):
amount: float
payment_method: str
user_id: str
class PaymentResult(Response):
success: bool
transaction_id: str | None = None
message: str = ""
class CreditCardHandler(CORRequestHandler[ProcessPaymentCommand, PaymentResult]):
async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
if request.payment_method == "credit_card":
return PaymentResult(success=True, transaction_id="cc_123")
return await self.next(request)
class PayPalHandler(CORRequestHandler[ProcessPaymentCommand, PaymentResult]):
async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
if request.payment_method == "paypal":
return PaymentResult(success=True, transaction_id="pp_123")
return await self.next(request)
class DefaultPaymentHandler(CORRequestHandler[ProcessPaymentCommand, PaymentResult]):
async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
return PaymentResult(success=False, message="Unsupported method")
handlers = [CreditCardHandler, PayPalHandler, DefaultPaymentHandler]
```
### Generated Sequence Diagram
#### Text Format
```text
sequenceDiagram
participant C as Chain
participant E as EventMediator
participant H1 as CreditCardHandler
participant H2 as PayPalHandler
participant H3 as DefaultPaymentHandler
Note over C: Chain Execution Flow
C->>H1: handle(request)
alt CreditCardHandler can handle
H1-->>C: result
Note over H1: Handler processed, chain stops
C->>H1: events
H1-->>C: List[Event]
C->>E: process events
Note over E: Events from CreditCardHandler processed
else
H1->>H2: next(request)
Note over H1: Cannot handle, passing to next
alt PayPalHandler can handle
H2-->>C: result
Note over H2: Handler processed, chain stops
C->>H2: events
H2-->>C: List[Event]
C->>E: process events
Note over E: Events from PayPalHandler processed
else
H2->>H3: next(request)
Note over H2: Cannot handle, passing to next
alt DefaultPaymentHandler can handle
H3-->>C: result
Note over H3: Handler processed (default)
C->>H3: events
H3-->>C: List[Event]
C->>E: process events
Note over E: Events from DefaultPaymentHandler processed
end
end
end
```
#### Rendered Diagram
```
sequenceDiagram
participant C as Chain
participant E as EventMediator
participant H1 as CreditCardHandler
participant H2 as PayPalHandler
participant H3 as DefaultPaymentHandler
Note over C: Chain Execution Flow
C->>H1: handle(request)
alt CreditCardHandler can handle
H1-->>C: result
Note over H1: Handler processed, chain stops
C->>H1: events
H1-->>C: List[Event]
C->>E: process events
Note over E: Events from CreditCardHandler processed
else
H1->>H2: next(request)
Note over H1: Cannot handle, passing to next
alt PayPalHandler can handle
H2-->>C: result
Note over H2: Handler processed, chain stops
C->>H2: events
H2-->>C: List[Event]
C->>E: process events
Note over E: Events from PayPalHandler processed
else
H2->>H3: next(request)
Note over H2: Cannot handle, passing to next
alt DefaultPaymentHandler can handle
H3-->>C: result
Note over H3: Handler processed (default)
C->>H3: events
H3-->>C: List[Event]
C->>E: process events
Note over E: Events from DefaultPaymentHandler processed
end
end
end
```
## Class Diagram
The Class diagram shows the complete type structure and relationships:
- CORRequestHandler base class with its methods
- Handler classes with their methods (`handle`, `next`, `events`)
- Request classes with their fields
- Response classes with their fields
- Relationships between classes (inheritance, chain links, usage, return types)
### Generated Class Diagram
#### Text Format
```text
classDiagram
class CORRequestHandler {
<>
+handle(request) Response | None
+next(request) Response | None
+set_next(handler) CORRequestHandler
+events: List[Event]
}
class CreditCardHandler {
+handle(request) Response | None
+next(request) Response | None
+events: List[Event]
}
class PayPalHandler {
+handle(request) Response | None
+next(request) Response | None
+events: List[Event]
}
class DefaultPaymentHandler {
+handle(request) Response | None
+next(request) Response | None
+events: List[Event]
}
class ProcessPaymentCommand {
+amount: float
+payment_method: str
+user_id: str
}
class PaymentResult {
+success: bool
+transaction_id: str | None
+message: str
}
%% Inheritance relationships
CORRequestHandler <|-- CreditCardHandler
CORRequestHandler <|-- PayPalHandler
CORRequestHandler <|-- DefaultPaymentHandler
%% Chain relationships (set_next)
CreditCardHandler --> PayPalHandler : set_next
PayPalHandler --> DefaultPaymentHandler : set_next
%% Handler to Request relationships
CreditCardHandler ..> ProcessPaymentCommand : uses
PayPalHandler ..> ProcessPaymentCommand : uses
DefaultPaymentHandler ..> ProcessPaymentCommand : uses
%% Handler to Response relationships
CreditCardHandler ..> PaymentResult : returns
PayPalHandler ..> PaymentResult : returns
DefaultPaymentHandler ..> PaymentResult : returns
```
#### Rendered Diagram
```
classDiagram
class CORRequestHandler {
<>
+handle(request) Response | None
+next(request) Response | None
+set_next(handler) CORRequestHandler
+events: List[Event]
}
class CreditCardHandler {
+handle(request) Response | None
+next(request) Response | None
+events: List[Event]
}
class PayPalHandler {
+handle(request) Response | None
+next(request) Response | None
+events: List[Event]
}
class DefaultPaymentHandler {
+handle(request) Response | None
+next(request) Response | None
+events: List[Event]
}
class ProcessPaymentCommand {
+amount: float
+payment_method: str
+user_id: str
}
class PaymentResult {
+success: bool
+transaction_id: str | None
+message: str
}
%% Inheritance relationships
CORRequestHandler <|-- CreditCardHandler
CORRequestHandler <|-- PayPalHandler
CORRequestHandler <|-- DefaultPaymentHandler
%% Chain relationships (set_next)
CreditCardHandler --> PayPalHandler : set_next
PayPalHandler --> DefaultPaymentHandler : set_next
%% Handler to Request relationships
CreditCardHandler ..> ProcessPaymentCommand : uses
PayPalHandler ..> ProcessPaymentCommand : uses
DefaultPaymentHandler ..> ProcessPaymentCommand : uses
%% Handler to Response relationships
CreditCardHandler ..> PaymentResult : returns
PayPalHandler ..> PaymentResult : returns
DefaultPaymentHandler ..> PaymentResult : returns
```
## Usage in Documentation
Generated diagrams can be:
- **Copied and pasted** into [Mermaid Live Editor](https://mermaid.live/) for visualization
- **Embedded directly** in Markdown files (GitHub/GitLab support Mermaid)
- **Used in documentation tools** (Confluence, Notion, etc.)
- **Included in README files** for better understanding
### Running the Example
To see the diagrams generated from a real handler chain, run:
```bash
python examples/cor/cor_mermaid.py
```
This will output both Sequence and Class diagrams in a format ready to copy and paste into any Mermaid-compatible viewer.
## API Reference
### CoRMermaid Class
#### `__init__(handlers: List[type[CORRequestHandler[Any, Any]]])`
Initialize Mermaid diagram generator.
**Parameters:**
- `handlers`: List of handler classes in chain order
#### `sequence() -> str`
Generate a Mermaid Sequence diagram showing chain execution flow.
**Returns:**
- A string containing the Mermaid Sequence diagram code
#### `class_diagram() -> str`
Generate a Mermaid Class diagram showing handler chain structure, types, and relationships.
**Returns:**
- A string containing the Mermaid Class diagram code
## See Also
- [Mermaid Overview](https://mkdocs.python-cqrs.dev/latest/mermaid/index.md) - Overview of Mermaid diagram generation
- [Chain of Responsibility Overview](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/index.md) - Learn about the Chain of Responsibility pattern implementation
- [Chain of Responsibility Examples](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/examples/index.md) - Complete examples
- [Chain of Responsibility Advanced Topics](https://mkdocs.python-cqrs.dev/latest/chain_of_responsibility/advanced/index.md) - Advanced usage patterns
- [Example: Basic CoR](https://github.com/vadikko2/python-cqrs/blob/master/examples/cor/cor_request_handler.py)
- [Example: CoR Mermaid Diagrams](https://github.com/vadikko2/python-cqrs/blob/master/examples/cor/cor_mermaid.py)
# Mermaid Diagram Generation for Saga
The package includes built-in support for generating Mermaid diagrams from Saga instances. This feature is perfect for documentation, visualization, and understanding saga structure and execution flow.
## Overview
The `SagaMermaid` class can generate two types of diagrams:
1. **Sequence Diagram** - Shows the execution flow, success/failure scenarios, and compensation logic
1. **Class Diagram** - Shows the type structure, relationships between Saga, steps, contexts, responses, and events
## Example
You can find a complete working example in the repository:
[**View Example: `saga_mermaid.py`**](https://github.com/vadikko2/python-cqrs/blob/master/examples/saga/saga_mermaid.py)
### Basic Usage
```python
from cqrs.mermaid.saga import SagaMermaid
from cqrs.saga.saga import Saga
from cqrs.saga.storage.memory import MemorySagaStorage
# Create your saga class (steps are defined as class attribute)
class OrderSaga(Saga[OrderContext]):
steps = [ReserveInventoryStep, ProcessPaymentStep, ShipOrderStep]
# Create saga instance
saga = OrderSaga()
# Create Mermaid generator
generator = SagaMermaid(saga)
# Generate Sequence diagram showing execution flow
sequence_diagram = generator.sequence()
print(sequence_diagram)
# Generate Class diagram showing type structure
class_diagram = generator.class_diagram()
print(class_diagram)
```
## Sequence Diagram
The Sequence diagram visualizes the complete execution flow of a saga, including:
- All saga steps in execution order (act methods)
- Successful execution flow
- Failure scenarios with automatic compensation
- Compensation flow in reverse order
### Example Saga Code
```python
from cqrs.saga.saga import Saga
from cqrs.handlers.saga import SagaStepHandler, SagaStepResult
from cqrs.saga.models import SagaContext
from cqrs.models.response import Response
import dataclasses
@dataclasses.dataclass
class OrderContext(SagaContext):
order_id: str
user_id: str
items: list[str]
total_amount: float
shipping_address: str
inventory_reservation_id: str | None = None
payment_id: str | None = None
shipment_id: str | None = None
class ReserveInventoryResponse(Response):
reservation_id: str
items_reserved: list[str]
class ProcessPaymentResponse(Response):
payment_id: str
amount_charged: float
transaction_id: str
class ShipOrderResponse(Response):
shipment_id: str
tracking_number: str
estimated_delivery: str
class ReserveInventoryStep(SagaStepHandler[OrderContext, ReserveInventoryResponse]):
# ... implementation
class ProcessPaymentStep(SagaStepHandler[OrderContext, ProcessPaymentResponse]):
# ... implementation
class ShipOrderStep(SagaStepHandler[OrderContext, ShipOrderResponse]):
# ... implementation
class OrderSaga(Saga[OrderContext]):
steps = [
ReserveInventoryStep,
ProcessPaymentStep,
ShipOrderStep,
]
saga = OrderSaga()
```
### Generated Sequence Diagram
#### Text Format
```text
sequenceDiagram
participant S as Saga
participant S1 as ReserveInventoryStep
participant S2 as ProcessPaymentStep
participant S3 as ShipOrderStep
Note over S: Successful Execution Flow
S->>S1: act()
S1-->>S: success
S->>S2: act()
S2-->>S: success
S->>S3: act()
S3-->>S: success
Note over S: Saga Completed
Note over S: Failure & Compensation Flow
S->>S1: act()
S1-->>S: success
S->>S2: act()
S2-->>S: success
S->>S3: act()
S3-->>S: error
Note over S: Compensation (reverse order)
S->>S2: compensate()
S2-->>S: success
S->>S1: compensate()
S1-->>S: success
Note over S: Saga Failed
```
#### Rendered Diagram
```
sequenceDiagram
participant S as Saga
participant S1 as ReserveInventoryStep
participant S2 as ProcessPaymentStep
participant S3 as ShipOrderStep
Note over S: Successful Execution Flow
S->>S1: act()
S1-->>S: success
S->>S2: act()
S2-->>S: success
S->>S3: act()
S3-->>S: success
Note over S: Saga Completed
Note over S: Failure & Compensation Flow
S->>S1: act()
S1-->>S: success
S->>S2: act()
S2-->>S: success
S->>S3: act()
S3-->>S: error
Note over S: Compensation (reverse order)
S->>S2: compensate()
S2-->>S: success
S->>S1: compensate()
S1-->>S: success
Note over S: Saga Failed
```
## Class Diagram
The Class diagram shows the complete type structure and relationships:
- Saga class with its methods and properties
- Step handler classes with their methods (`act`, `compensate`, `events`)
- Context classes with their fields
- Response classes with their fields
- Relationships between classes (composition, usage, return types)
### Generated Class Diagram
#### Text Format
```text
classDiagram
class Saga {
+steps: List[SagaStepHandler]
+transaction(context) SagaTransaction
}
class ReserveInventoryStep {
+act(context) SagaStepResult
+compensate(context) void
+events: List[Event]
}
class ProcessPaymentStep {
+act(context) SagaStepResult
+compensate(context) void
+events: List[Event]
}
class ShipOrderStep {
+act(context) SagaStepResult
+compensate(context) void
+events: List[Event]
}
class OrderContext {
+order_id: str
+user_id: str
+items: list[str]
+total_amount: float
+shipping_address: str
+inventory_reservation_id: str | None
+payment_id: str | None
+shipment_id: str | None
}
class ReserveInventoryResponse {
+reservation_id: str
+items_reserved: list[str]
}
class ProcessPaymentResponse {
+payment_id: str
+amount_charged: float
+transaction_id: str
}
class ShipOrderResponse {
+shipment_id: str
+tracking_number: str
+estimated_delivery: str
}
%% Saga relationships
Saga --> ReserveInventoryStep : contains
Saga --> ProcessPaymentStep : contains
Saga --> ShipOrderStep : contains
%% Step to Context relationships
ReserveInventoryStep ..> OrderContext : uses
ProcessPaymentStep ..> OrderContext : uses
ShipOrderStep ..> OrderContext : uses
%% Step to Response relationships
ReserveInventoryStep ..> ReserveInventoryResponse : returns
ProcessPaymentStep ..> ProcessPaymentResponse : returns
ShipOrderStep ..> ShipOrderResponse : returns
```
#### Rendered Diagram
```
classDiagram
class Saga {
+steps: List[SagaStepHandler]
+transaction(context) SagaTransaction
}
class ReserveInventoryStep {
+act(context) SagaStepResult
+compensate(context) void
+events: List[Event]
}
class ProcessPaymentStep {
+act(context) SagaStepResult
+compensate(context) void
+events: List[Event]
}
class ShipOrderStep {
+act(context) SagaStepResult
+compensate(context) void
+events: List[Event]
}
class OrderContext {
+order_id: str
+user_id: str
+items: list[str]
+total_amount: float
+shipping_address: str
+inventory_reservation_id: str | None
+payment_id: str | None
+shipment_id: str | None
}
class ReserveInventoryResponse {
+reservation_id: str
+items_reserved: list[str]
}
class ProcessPaymentResponse {
+payment_id: str
+amount_charged: float
+transaction_id: str
}
class ShipOrderResponse {
+shipment_id: str
+tracking_number: str
+estimated_delivery: str
}
%% Saga relationships
Saga --> ReserveInventoryStep : contains
Saga --> ProcessPaymentStep : contains
Saga --> ShipOrderStep : contains
%% Step to Context relationships
ReserveInventoryStep ..> OrderContext : uses
ProcessPaymentStep ..> OrderContext : uses
ShipOrderStep ..> OrderContext : uses
%% Step to Response relationships
ReserveInventoryStep ..> ReserveInventoryResponse : returns
ProcessPaymentStep ..> ProcessPaymentResponse : returns
ShipOrderStep ..> ShipOrderResponse : returns
```
## Usage in Documentation
Generated diagrams can be:
- **Copied and pasted** into [Mermaid Live Editor](https://mermaid.live/) for visualization
- **Embedded directly** in Markdown files (GitHub/GitLab support Mermaid)
- **Used in documentation tools** (Confluence, Notion, etc.)
- **Included in README files** for better understanding
### Running the Example
To see the diagrams generated from a real saga, run:
```bash
python examples/saga/saga_mermaid.py
```
This will output both Sequence and Class diagrams in a format ready to copy and paste into any Mermaid-compatible viewer.
## API Reference
### SagaMermaid Class
#### `__init__(saga: Saga[Any])`
Initialize Mermaid diagram generator.
**Parameters:**
- `saga`: The saga instance to generate diagram for
#### `sequence() -> str`
Generate a Mermaid Sequence diagram showing all saga steps and compensations.
**Returns:**
- A string containing the Mermaid Sequence diagram code
#### `class_diagram() -> str`
Generate a Mermaid Class diagram showing saga structure, types, and relationships.
**Returns:**
- A string containing the Mermaid Class diagram code
## See Also
- [Mermaid Overview](https://mkdocs.python-cqrs.dev/latest/mermaid/index.md) - Overview of Mermaid diagram generation
- [Saga Pattern Overview](https://mkdocs.python-cqrs.dev/latest/saga/index.md) - Learn about the Saga pattern implementation
- [Saga Flow Diagrams](https://mkdocs.python-cqrs.dev/latest/saga/flow/index.md) - Understanding saga execution flow
- [Saga Recovery](https://mkdocs.python-cqrs.dev/latest/saga/recovery/index.md) - Understanding saga recovery mechanisms
- [Saga Compensation](https://mkdocs.python-cqrs.dev/latest/saga/compensation/index.md) - Compensation strategies
- [Saga Examples](https://mkdocs.python-cqrs.dev/latest/saga/examples/index.md) - Complete examples
- [Example: Basic Saga](https://github.com/vadikko2/python-cqrs/blob/master/examples/saga/saga.py)
- [Example: Saga Mermaid Diagrams](https://github.com/vadikko2/python-cqrs/blob/master/examples/saga/saga_mermaid.py)
# Integrations
# FastAPI Integration
## Overview
FastAPI integration with `python-cqrs` allows you to build RESTful APIs where FastAPI handles HTTP layer (routing, validation, serialization) while CQRS handles business logic through command/query handlers.
| Benefit | Description |
| -------------------------- | -------------------------------------------- |
| **Separation of Concerns** | HTTP layer vs business logic |
| **Type Safety** | Full Pydantic v2 support |
| **Testability** | Easy to test handlers independently |
| **Scalability** | Commands and queries can scale independently |
Prerequisites
Understanding of [Bootstrap](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md), [Request Handlers](https://mkdocs.python-cqrs.dev/latest/request_handler/index.md), and [Stream Handling](https://mkdocs.python-cqrs.dev/latest/stream_handling/index.md) is recommended.
Quick Start
This integration shows how to use mediators created via [Bootstrap](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md) in FastAPI endpoints. See [Stream Handling](https://mkdocs.python-cqrs.dev/latest/stream_handling/index.md) for SSE examples.
## Setup
First, install the required dependencies:
```bash
pip install fastapi uvicorn python-cqrs di
```
## Mediator Dependency Injection
The recommended way to inject mediators into FastAPI endpoints is using `Depends()` with factory functions. This ensures proper dependency management and allows for easy testing.
### Basic Mediator Factory
```python
import di
import fastapi
import cqrs
from cqrs.bootstrap import requests as bootstrap
def mediator_factory() -> cqrs.RequestMediator:
"""Factory function for RequestMediator dependency injection."""
container = di.Container()
# Your mappers (commands_mapper, queries_mapper, events_mapper)
# should be defined elsewhere in your application
return bootstrap.bootstrap(
di_container=container,
commands_mapper=commands_mapper,
queries_mapper=queries_mapper,
domain_events_mapper=events_mapper,
)
# Use in endpoint
@app.post("/users")
async def create_user(
command: CreateUserCommand,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
):
return await mediator.send(command)
```
### Mediator Factory Comparison
| Factory Type | Performance | Use Case |
| ----------------------- | ------------------------------------ | ------------------------ |
| **Basic Factory** | Creates new mediator per request | Development, testing |
| **Singleton Factory** | Reuses mediator across requests | Production (recommended) |
| **Per-Request Factory** | Creates mediator per request with DI | Advanced scenarios |
Production Recommendation
Use singleton mediator factory for better performance. The mediator is thread-safe and can be safely reused.
### Singleton Mediator (Recommended for Production)
For better performance, you can create a singleton mediator that's reused across requests:
```python
import functools
@functools.lru_cache(maxsize=1)
def mediator_factory() -> cqrs.RequestMediator:
"""Singleton mediator factory - created once and reused."""
container = di.Container()
return bootstrap.bootstrap(
di_container=container,
commands_mapper=commands_mapper,
queries_mapper=queries_mapper,
domain_events_mapper=events_mapper,
)
```
### Streaming Mediator Factory
For streaming endpoints, create a separate factory:
```python
@functools.lru_cache(maxsize=1)
def streaming_mediator_factory() -> cqrs.StreamingRequestMediator:
"""Factory for StreamingRequestMediator."""
container = di.Container()
return bootstrap.bootstrap_streaming(
di_container=container,
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
max_concurrent_event_handlers=5,
concurrent_event_handle_enable=True,
)
```
### Event Emitter Factory
For background task processing:
```python
from cqrs.bootstrap import requests as bootstrap
from cqrs.events import EventEmitter
@functools.lru_cache(maxsize=1)
def event_emitter_factory() -> EventEmitter:
"""Factory for EventEmitter used in background tasks."""
container = di.Container()
return bootstrap.setup_event_emitter(
container=container,
domain_events_mapper=events_mapper,
)
```
### Using Multiple Mediators
You can inject multiple mediators into a single endpoint:
```python
@app.post("/process")
async def process_request(
command: ProcessCommand,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
streaming_mediator: cqrs.StreamingRequestMediator = fastapi.Depends(
streaming_mediator_factory
),
):
# Use appropriate mediator based on logic
if command.use_streaming:
async for result in streaming_mediator.stream(command):
yield result
else:
return await mediator.send(command)
```
## Command Handling (POST, PUT, DELETE)
Commands modify state and typically don't return data (or return minimal data). Use POST for creation, PUT for updates, and DELETE for deletion.
### POST Request (Create Command)
```python
import fastapi
import cqrs
@app.post("/users", status_code=201)
async def create_user(
command: CreateUserCommand,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> UserCreatedResponse:
"""Create a new user."""
return await mediator.send(command)
```
### PUT Request (Update Command)
```python
@app.put("/users/{user_id}")
async def update_user(
user_id: str,
command: UpdateUserCommand,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> UserUpdatedResponse:
"""Update an existing user."""
command.user_id = user_id # Set from path parameter
return await mediator.send(command)
```
### DELETE Request (Delete Command)
```python
@app.delete("/users/{user_id}", status_code=200)
async def delete_user(
user_id: str,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> UserDeletedResponse:
"""Delete a user."""
command = DeleteUserCommand(user_id=user_id)
return await mediator.send(command)
```
### Command with Path and Body Parameters
```python
@app.put("/users/{user_id}/profile")
async def update_user_profile(
user_id: str,
profile_data: ProfileUpdateRequest,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> ProfileResponse:
"""Update user profile with path and body parameters."""
command = UpdateProfileCommand(
user_id=user_id,
**profile_data.model_dump()
)
return await mediator.send(command)
```
### Command with Query Parameters
```python
@app.post("/users/{user_id}/notify")
async def notify_user(
user_id: str,
notification_type: str = fastapi.Query(...),
priority: str = fastapi.Query("normal"),
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> NotificationResponse:
"""Send notification to user with query parameters."""
command = NotifyUserCommand(
user_id=user_id,
notification_type=notification_type,
priority=priority
)
return await mediator.send(command)
```
## Query Handling (GET)
Queries read data without modifying state. Use GET requests for queries.
### Basic GET Request
```python
@app.get("/users/{user_id}")
async def get_user(
user_id: str,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> UserResponse:
"""Get user by ID."""
query = GetUserQuery(user_id=user_id)
return await mediator.send(query)
```
### GET with Query Parameters
```python
@app.get("/users")
async def list_users(
page: int = fastapi.Query(1, ge=1),
page_size: int = fastapi.Query(10, ge=1, le=100),
search: str | None = fastapi.Query(None),
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> ListUsersResponse:
"""List users with pagination and search."""
query = ListUsersQuery(
page=page,
page_size=page_size,
search=search
)
return await mediator.send(query)
```
### GET with Multiple Path Parameters
```python
@app.get("/users/{user_id}/orders/{order_id}")
async def get_user_order(
user_id: str,
order_id: str,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> OrderResponse:
"""Get specific order for a user."""
query = GetUserOrderQuery(user_id=user_id, order_id=order_id)
return await mediator.send(query)
```
### GET with Optional Parameters
```python
@app.get("/users/{user_id}/analytics")
async def get_user_analytics(
user_id: str,
start_date: str | None = fastapi.Query(None),
end_date: str | None = fastapi.Query(None),
include_details: bool = fastapi.Query(False),
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> AnalyticsResponse:
"""Get user analytics with optional date range."""
query = GetUserAnalyticsQuery(
user_id=user_id,
start_date=start_date,
end_date=end_date,
include_details=include_details
)
return await mediator.send(query)
```
## Event Handling with Background Tasks
Sometimes you need to process events asynchronously using FastAPI's `BackgroundTasks`. This is useful when you want to:
- Process events after returning HTTP response
- Handle events from external systems
- Decouple event processing from request handling
### POST Endpoint with Background Task
```python
from fastapi import BackgroundTasks
@app.post("/events/process", status_code=202)
async def process_event(
event: UserCreatedEvent,
background_tasks: BackgroundTasks,
emitter: EventEmitter = fastapi.Depends(event_emitter_factory),
) -> dict:
"""Process event asynchronously using background tasks."""
background_tasks.add_task(emitter.emit, event)
return {
"status": "accepted",
"event_id": event.event_id,
"message": "Event will be processed in background"
}
```
### PUT Endpoint with Background Task
```python
@app.put("/events/{event_id}/process", status_code=202)
async def process_event_by_id(
event_id: str,
event_data: dict,
background_tasks: BackgroundTasks,
emitter: EventEmitter = fastapi.Depends(event_emitter_factory),
) -> dict:
"""Process event by ID asynchronously."""
# Create event from event_data
event = UserCreatedEvent(
user_id=event_data.get("user_id", event_id),
email=event_data.get("email", "")
)
background_tasks.add_task(emitter.emit, event)
return {
"status": "accepted",
"event_id": event_id,
"message": "Event processing started"
}
```
### Processing Multiple Events
```python
@app.post("/events/batch", status_code=202)
async def process_events_batch(
events: list[UserCreatedEvent],
background_tasks: BackgroundTasks,
emitter: EventEmitter = fastapi.Depends(event_emitter_factory),
) -> dict:
"""Process multiple events in background."""
for event in events:
background_tasks.add_task(emitter.emit, event)
return {
"status": "accepted",
"events_count": len(events),
"message": f"{len(events)} events will be processed in background"
}
```
### Event Processing with Error Handling
```python
async def emit_event_safe(emitter: EventEmitter, event: cqrs.Event):
"""Safely emit event with error handling."""
try:
await emitter.emit(event)
except Exception as e:
# Log error, send to error queue, etc.
print(f"Error processing event {event.event_id}: {e}")
@app.post("/events", status_code=202)
async def process_event_safe(
event: UserCreatedEvent,
background_tasks: BackgroundTasks,
emitter: EventEmitter = fastapi.Depends(event_emitter_factory),
) -> dict:
"""Process event with error handling."""
background_tasks.add_task(emit_event_safe, emitter, event)
return {
"status": "accepted",
"event_id": event.event_id
}
```
## Server-Sent Events (SSE) with Streaming
Server-Sent Events (SSE) allow you to stream data to clients in real-time. This is perfect for:
- Long-running operations with progress updates
- Batch processing with real-time feedback
- File processing with incremental results
### Basic SSE Endpoint
```python
import json
@app.post("/process-files")
async def process_files_stream(
command: ProcessFilesCommand,
mediator: cqrs.StreamingRequestMediator = fastapi.Depends(
streaming_mediator_factory
),
) -> fastapi.responses.StreamingResponse:
"""Process files and stream results via SSE."""
async def generate_sse():
"""Generate SSE events from streaming mediator."""
try:
# Send initial event
yield f"data: {json.dumps({'type': 'start', 'message': f'Processing {len(command.file_ids)} files...'})}\n\n"
processed_count = 0
# Stream results from mediator
async for result in mediator.stream(command):
if result is None:
continue
processed_count += 1
# Format result as SSE event
sse_data = {
"type": "progress",
"data": {
"file_id": result.file_id,
"status": result.status,
"progress": {
"current": processed_count,
"total": len(command.file_ids),
"percentage": int(
(processed_count / len(command.file_ids)) * 100
),
},
},
}
yield f"data: {json.dumps(sse_data)}\n\n"
# Send completion event
completion_data = {
"type": "complete",
"message": f"Successfully processed {processed_count} files",
"total_processed": processed_count,
}
yield f"data: {json.dumps(completion_data)}\n\n"
except Exception as e:
error_data = {
"type": "error",
"message": str(e),
}
yield f"data: {json.dumps(error_data)}\n\n"
return fastapi.responses.StreamingResponse(
generate_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable buffering in nginx
},
)
```
### SSE with Query Parameters
```python
@app.post("/process")
async def process_with_options(
command: ProcessCommand,
include_details: bool = fastapi.Query(False),
mediator: cqrs.StreamingRequestMediator = fastapi.Depends(
streaming_mediator_factory
),
) -> fastapi.responses.StreamingResponse:
"""Process with streaming and query parameters."""
async def generate_sse():
yield f"data: {json.dumps({'type': 'start'})}\n\n"
async for result in mediator.stream(command):
if result is None:
continue
sse_data = {
"type": "progress",
"data": result.model_dump(include=include_details),
}
yield f"data: {json.dumps(sse_data)}\n\n"
yield f"data: {json.dumps({'type': 'complete'})}\n\n"
return fastapi.responses.StreamingResponse(
generate_sse(),
media_type="text/event-stream",
)
```
### SSE with Path Parameters
```python
@app.post("/users/{user_id}/process")
async def process_user_files(
user_id: str,
command: ProcessFilesCommand,
mediator: cqrs.StreamingRequestMediator = fastapi.Depends(
streaming_mediator_factory
),
) -> fastapi.responses.StreamingResponse:
"""Process files for a specific user."""
# Add user_id to command
command.user_id = user_id
async def generate_sse():
yield f"data: {json.dumps({'type': 'start', 'user_id': user_id})}\n\n"
async for result in mediator.stream(command):
if result is None:
continue
yield f"data: {json.dumps(result.model_dump())}\n\n"
yield f"data: {json.dumps({'type': 'complete'})}\n\n"
return fastapi.responses.StreamingResponse(
generate_sse(),
media_type="text/event-stream",
)
```
## Complete Example
Here's a complete FastAPI application demonstrating all integration patterns:
```python
import functools
import json
from fastapi import BackgroundTasks
import di
import fastapi
import cqrs
from cqrs.bootstrap import requests as bootstrap
from cqrs.events import EventEmitter
from cqrs.message_brokers import devnull
app = fastapi.FastAPI(title="CQRS FastAPI Integration Example")
# ============================================================================
# Mediator Factories
# ============================================================================
@functools.lru_cache(maxsize=1)
def mediator_factory() -> cqrs.RequestMediator:
"""Factory for RequestMediator - singleton pattern."""
container = di.Container()
return bootstrap.bootstrap(
di_container=container,
commands_mapper=commands_mapper,
queries_mapper=queries_mapper,
domain_events_mapper=events_mapper,
message_broker=devnull.DevnullMessageBroker(),
)
@functools.lru_cache(maxsize=1)
def streaming_mediator_factory() -> cqrs.StreamingRequestMediator:
"""Factory for StreamingRequestMediator - singleton pattern."""
container = di.Container()
return bootstrap.bootstrap_streaming(
di_container=container,
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
max_concurrent_event_handlers=5,
concurrent_event_handle_enable=True,
)
@functools.lru_cache(maxsize=1)
def event_emitter_factory() -> EventEmitter:
"""Factory for EventEmitter - singleton pattern."""
container = di.Container()
return bootstrap.setup_event_emitter(
container=container,
domain_events_mapper=events_mapper,
)
# ============================================================================
# Command Endpoints
# ============================================================================
@app.post("/users", status_code=201)
async def create_user(
command: CreateUserCommand,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> UserCreatedResponse:
"""Create a new user."""
return await mediator.send(command)
@app.put("/users/{user_id}")
async def update_user(
user_id: str,
command: UpdateUserCommand,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> UserUpdatedResponse:
"""Update an existing user."""
command.user_id = user_id
return await mediator.send(command)
@app.delete("/users/{user_id}", status_code=200)
async def delete_user(
user_id: str,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> UserDeletedResponse:
"""Delete a user."""
command = DeleteUserCommand(user_id=user_id)
return await mediator.send(command)
# ============================================================================
# Query Endpoints
# ============================================================================
@app.get("/users/{user_id}")
async def get_user(
user_id: str,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> UserResponse:
"""Get user by ID."""
query = GetUserQuery(user_id=user_id)
return await mediator.send(query)
@app.get("/users")
async def list_users(
page: int = fastapi.Query(1, ge=1),
page_size: int = fastapi.Query(10, ge=1, le=100),
search: str | None = fastapi.Query(None),
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> ListUsersResponse:
"""List users with pagination."""
query = ListUsersQuery(page=page, page_size=page_size, search=search)
return await mediator.send(query)
# ============================================================================
# Event Processing Endpoints
# ============================================================================
@app.post("/events", status_code=202)
async def process_event(
event: UserCreatedEvent,
background_tasks: BackgroundTasks,
emitter: EventEmitter = fastapi.Depends(event_emitter_factory),
) -> dict:
"""Process event asynchronously."""
background_tasks.add_task(emitter.emit, event)
return {
"status": "accepted",
"event_id": event.event_id,
"message": "Event will be processed in background"
}
@app.put("/events/{event_id}/process", status_code=202)
async def process_event_by_id(
event_id: str,
event_data: dict,
background_tasks: BackgroundTasks,
emitter: EventEmitter = fastapi.Depends(event_emitter_factory),
) -> dict:
"""Process event by ID asynchronously."""
event = UserCreatedEvent(
user_id=event_data.get("user_id", event_id),
email=event_data.get("email", "")
)
background_tasks.add_task(emitter.emit, event)
return {
"status": "accepted",
"event_id": event_id
}
# ============================================================================
# Streaming Endpoints
# ============================================================================
@app.post("/process-files")
async def process_files_stream(
command: ProcessFilesCommand,
mediator: cqrs.StreamingRequestMediator = fastapi.Depends(
streaming_mediator_factory
),
) -> fastapi.responses.StreamingResponse:
"""Process files and stream results via SSE."""
async def generate_sse():
yield f"data: {json.dumps({'type': 'start', 'message': f'Processing {len(command.file_ids)} files...'})}\n\n"
processed_count = 0
async for result in mediator.stream(command):
if result is None:
continue
processed_count += 1
sse_data = {
"type": "progress",
"data": result.model_dump(),
"progress": {
"current": processed_count,
"total": len(command.file_ids),
"percentage": int((processed_count / len(command.file_ids)) * 100),
},
}
yield f"data: {json.dumps(sse_data)}\n\n"
yield f"data: {json.dumps({'type': 'complete', 'total_processed': processed_count})}\n\n"
return fastapi.responses.StreamingResponse(
generate_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
```
## Best Practices
### 1. Use Singleton Factories
Use `@functools.lru_cache` to create singleton mediators for better performance:
```python
@functools.lru_cache(maxsize=1)
def mediator_factory() -> cqrs.RequestMediator:
return bootstrap.bootstrap(...)
```
### 2. Separate Factories
Create separate factories for different mediator types:
```python
def mediator_factory() -> cqrs.RequestMediator:
# For commands and queries
pass
def streaming_mediator_factory() -> cqrs.StreamingRequestMediator:
# For streaming requests
pass
def event_emitter_factory() -> EventEmitter:
# For background event processing
pass
```
### 3. Error Handling
Handle errors appropriately in endpoints:
```python
from fastapi import HTTPException
@app.post("/users")
async def create_user(
command: CreateUserCommand,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
) -> UserCreatedResponse:
try:
return await mediator.send(command)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail="Internal server error")
```
### 4. Response Models
Always define response models for type safety:
```python
@app.post("/users", response_model=UserCreatedResponse, status_code=201)
async def create_user(...) -> UserCreatedResponse:
...
```
### 5. Background Tasks
Use background tasks for long-running event processing:
```python
@app.post("/events")
async def process_event(
event: UserCreatedEvent,
background_tasks: BackgroundTasks,
emitter: EventEmitter = fastapi.Depends(event_emitter_factory),
) -> dict:
background_tasks.add_task(emitter.emit, event)
return {"status": "accepted"}
```
### 6. SSE Headers
Always set proper headers for SSE:
```python
return fastapi.responses.StreamingResponse(
generate_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # For nginx
},
)
```
### 7. Path Parameters
Extract path parameters and set them in commands/queries:
```python
@app.put("/users/{user_id}")
async def update_user(
user_id: str,
command: UpdateUserCommand,
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
):
command.user_id = user_id # Set from path
return await mediator.send(command)
```
### 8. Query Parameters
Use FastAPI Query for validation and defaults:
```python
@app.get("/users")
async def list_users(
page: int = fastapi.Query(1, ge=1),
page_size: int = fastapi.Query(10, ge=1, le=100),
mediator: cqrs.RequestMediator = fastapi.Depends(mediator_factory),
):
query = ListUsersQuery(page=page, page_size=page_size)
return await mediator.send(query)
```
# FastStream Integration
## Overview
FastStream is a powerful framework for building event-driven applications with message brokers. The `python-cqrs` package integrates seamlessly with FastStream to process events from Kafka and RabbitMQ using CQRS event handlers.
**Key Features:**
- **Kafka Support** — Process events from Apache Kafka topics using `aiokafka`
- **RabbitMQ Support** — Process events from RabbitMQ queues using `aiopika`
- **Type Safety** — Full Pydantic v2 support for event payloads
- **Dependency Injection** — Use FastStream's `Depends()` for mediator injection
- **Error Handling** — Built-in deserialization error handling
- **Auto-commit Control** — Fine-grained control over message acknowledgment
Prerequisites
Understanding of [Event Handling](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md) and [Bootstrap](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md) is required. This integration shows how to consume events from message brokers.
Related Topics
- [Event Producing](https://mkdocs.python-cqrs.dev/latest/event_producing/index.md) — For publishing events to message brokers
- [Protobuf Integration](https://mkdocs.python-cqrs.dev/latest/protobuf/index.md) — Opt-in Protobuf produce / consume codecs
- [Transaction Outbox](https://mkdocs.python-cqrs.dev/latest/outbox/index.md) — For reliable event delivery
## Setup
Install the required dependencies:
```bash
# For Kafka
pip install faststream[kafka] python-cqrs di orjson
# For RabbitMQ
pip install faststream[rabbit] python-cqrs di orjson
# Or install both
pip install faststream[kafka,rabbit] python-cqrs di orjson
```
## Kafka Integration (aiokafka)
FastStream provides excellent support for Apache Kafka through `aiokafka`. This section shows how to consume events from Kafka topics and process them using CQRS event handlers.
### Basic Kafka Consumer Setup
```python
import di
import faststream
import cqrs
from faststream import kafka
from cqrs.bootstrap import events as bootstrap
from cqrs import deserializers
# Create Kafka broker
broker = kafka.KafkaBroker(bootstrap_servers=["localhost:9092"])
app = faststream.FastStream(broker)
# Event Mediator Factory
def mediator_factory() -> cqrs.EventMediator:
container = di.Container()
def events_mapper(mapper: cqrs.EventMap) -> None:
# Your event mappings here
mapper.bind(
cqrs.NotificationEvent[UserCreatedPayload],
UserCreatedEventHandler
)
return bootstrap.bootstrap(
di_container=container,
events_mapper=events_mapper,
)
# Kafka subscriber
@broker.subscriber(
"user_events",
group_id="my-service",
auto_commit=False,
auto_offset_reset="earliest",
)
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload],
msg: kafka.KafkaMessage,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
await mediator.send(body)
await msg.ack()
```
### Kafka with JSON Deserialization
```python
from cqrs import deserializers
@broker.subscriber(
"user_events",
group_id="my-service",
auto_commit=False,
value_deserializer=deserializers.JsonDeserializer(
model=cqrs.NotificationEvent[UserCreatedPayload],
),
)
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload]
| deserializers.DeserializeJsonError
| None,
msg: kafka.KafkaMessage,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
# Handle deserialization errors
if isinstance(body, deserializers.DeserializeJsonError):
print(f"Deserialization error: {body}")
await msg.nack()
return
if body is not None:
await mediator.send(body)
await msg.ack()
```
### Kafka with Custom Decoder
```python
from faststream import types
async def empty_message_decoder(
msg: kafka.KafkaMessage,
original_decoder: typing.Callable[
[kafka.KafkaMessage],
typing.Awaitable[types.DecodedMessage],
],
) -> types.DecodedMessage | None:
"""Skip empty messages."""
if not msg.body:
return None
return await original_decoder(msg)
@broker.subscriber(
"user_events",
group_id="my-service",
decoder=empty_message_decoder,
value_deserializer=deserializers.JsonDeserializer(
model=cqrs.NotificationEvent[UserCreatedPayload],
),
)
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload] | None,
msg: kafka.KafkaMessage,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
if body is not None:
await mediator.send(body)
await msg.ack()
```
### Kafka Consumer Configuration
```python
@broker.subscriber(
"user_events",
group_id="my-service",
auto_commit=False, # Manual commit control
auto_offset_reset="earliest", # Start from beginning
enable_auto_commit=False,
max_poll_records=100, # Batch size
value_deserializer=deserializers.JsonDeserializer(
model=cqrs.NotificationEvent[UserCreatedPayload],
),
)
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload],
msg: kafka.KafkaMessage,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
try:
await mediator.send(body)
await msg.ack() # Acknowledge after successful processing
except Exception as e:
print(f"Error processing event: {e}")
await msg.nack() # Negative acknowledgment on error
```
### Multiple Kafka Topics
```python
@broker.subscriber(
"user_events",
group_id="my-service",
)
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload],
msg: kafka.KafkaMessage,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
await mediator.send(body)
await msg.ack()
@broker.subscriber(
"order_events",
group_id="my-service",
)
async def handle_order_event(
body: cqrs.NotificationEvent[OrderCreatedPayload],
msg: kafka.KafkaMessage,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
await mediator.send(body)
await msg.ack()
```
## RabbitMQ Integration (aiopika)
FastStream also supports RabbitMQ through `aiopika`. This section shows how to consume events from RabbitMQ queues.
### Basic RabbitMQ Consumer Setup
```python
import di
import faststream
import cqrs
from faststream import rabbitmq
from cqrs.bootstrap import events as bootstrap
from cqrs import deserializers
# Create RabbitMQ broker
broker = rabbitmq.RabbitBroker("amqp://guest:guest@localhost:5672/")
app = faststream.FastStream(broker)
# Event Mediator Factory
def mediator_factory() -> cqrs.EventMediator:
container = di.Container()
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(
cqrs.NotificationEvent[UserCreatedPayload],
UserCreatedEventHandler
)
return bootstrap.bootstrap(
di_container=container,
events_mapper=events_mapper,
)
# RabbitMQ subscriber
@broker.subscriber("user_events")
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload],
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
await mediator.send(body)
```
### RabbitMQ with JSON Deserialization
```python
@broker.subscriber(
"user_events",
parser=deserializers.JsonDeserializer(
model=cqrs.NotificationEvent[UserCreatedPayload],
),
)
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload]
| deserializers.DeserializeJsonError,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
if isinstance(body, deserializers.DeserializeJsonError):
print(f"Deserialization error: {body}")
return
await mediator.send(body)
```
### RabbitMQ Queue Configuration
```python
@broker.subscriber(
"user_events",
queue="user_events_queue",
exchange="events",
routing_key="user.created",
durable=True, # Make queue durable
auto_delete=False,
parser=deserializers.JsonDeserializer(
model=cqrs.NotificationEvent[UserCreatedPayload],
),
)
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload],
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
await mediator.send(body)
```
### RabbitMQ with Manual Acknowledgment
```python
@broker.subscriber(
"user_events",
ack=True, # Enable manual acknowledgment
)
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload],
message: rabbitmq.RabbitMessage,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
try:
await mediator.send(body)
await message.ack() # Acknowledge after successful processing
except Exception as e:
print(f"Error processing event: {e}")
await message.nack() # Negative acknowledgment on error
```
### RabbitMQ Multiple Queues
```python
@broker.subscriber("user_events")
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload],
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
await mediator.send(body)
@broker.subscriber("order_events")
async def handle_order_event(
body: cqrs.NotificationEvent[OrderCreatedPayload],
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
await mediator.send(body)
```
For opt-in Protobuf consumers (`ProtobufDeserializer`), see [Protobuf Integration](https://mkdocs.python-cqrs.dev/latest/protobuf/index.md).
## Event Mediator Factory
The event mediator factory is crucial for dependency injection in FastStream subscribers. Here are different patterns:
### Singleton Mediator (Recommended)
```python
import functools
import di
import cqrs
from cqrs.bootstrap import events as bootstrap
@functools.lru_cache(maxsize=1)
def mediator_factory() -> cqrs.EventMediator:
"""Singleton mediator - created once and reused."""
container = di.Container()
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(
cqrs.NotificationEvent[UserCreatedPayload],
UserCreatedEventHandler
)
return bootstrap.bootstrap(
di_container=container,
events_mapper=events_mapper,
)
```
### Per-Request Mediator
```python
def mediator_factory() -> cqrs.EventMediator:
"""Create new mediator for each request."""
container = di.Container()
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(
cqrs.NotificationEvent[UserCreatedPayload],
UserCreatedEventHandler
)
return bootstrap.bootstrap(
di_container=container,
events_mapper=events_mapper,
)
```
### Mediator with Custom Middlewares
```python
import functools
from cqrs.middlewares import base
class EventLoggingMiddleware(base.Middleware):
async def __call__(self, request: cqrs.Request, handle):
print(f"Processing event: {type(request).__name__}")
result = await handle(request)
print(f"Event processed: {type(request).__name__}")
return result
@functools.lru_cache(maxsize=1)
def mediator_factory() -> cqrs.EventMediator:
container = di.Container()
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(
cqrs.NotificationEvent[UserCreatedPayload],
UserCreatedEventHandler
)
return bootstrap.bootstrap(
di_container=container,
events_mapper=events_mapper,
middlewares=[EventLoggingMiddleware()],
)
```
## Event Deserialization
FastStream requires proper deserialization of messages from brokers. The `python-cqrs` package provides `JsonDeserializer` for this purpose.
### Basic Deserialization
```python
from cqrs import deserializers
@broker.subscriber(
"user_events",
value_deserializer=deserializers.JsonDeserializer(
model=cqrs.NotificationEvent[UserCreatedPayload],
),
)
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload]
| deserializers.DeserializeJsonError
| None,
msg: kafka.KafkaMessage,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
if isinstance(body, deserializers.DeserializeJsonError):
# Handle deserialization error
print(f"Failed to deserialize: {body}")
await msg.nack()
return
if body is not None:
await mediator.send(body)
await msg.ack()
```
### Error Handling
```python
@broker.subscriber(
"user_events",
value_deserializer=deserializers.JsonDeserializer(
model=cqrs.NotificationEvent[UserCreatedPayload],
),
)
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload]
| deserializers.DeserializeJsonError
| None,
msg: kafka.KafkaMessage,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
# Check for deserialization errors
if isinstance(body, deserializers.DeserializeJsonError):
# Log error and send to DLQ (Dead Letter Queue)
print(f"Deserialization error: {body.error}")
await msg.nack() # Don't acknowledge, message will be retried
return
# Check for None (empty message)
if body is None:
await msg.ack() # Acknowledge empty messages
return
# Process valid event
try:
await mediator.send(body)
await msg.ack()
except Exception as e:
print(f"Error processing event: {e}")
await msg.nack() # Retry on processing error
```
## Complete Examples
### Kafka Complete Example
```python
import asyncio
import functools
import logging
import typing
import di
import faststream
import orjson
import pydantic
from faststream import kafka, types
import cqrs
from cqrs import deserializers
from cqrs.bootstrap import events as bootstrap
logging.basicConfig(level=logging.INFO)
logging.getLogger("aiokafka").setLevel(logging.ERROR)
# Create Kafka broker
broker = kafka.KafkaBroker(bootstrap_servers=["localhost:9092"])
app = faststream.FastStream(broker)
# Event payload
class UserCreatedPayload(pydantic.BaseModel):
user_id: str
email: str
name: str
# Event handler (defined elsewhere)
class UserCreatedEventHandler(
cqrs.EventHandler[cqrs.NotificationEvent[UserCreatedPayload]]
):
async def handle(
self, event: cqrs.NotificationEvent[UserCreatedPayload]
) -> None:
print(f"User {event.payload.user_id} created: {event.payload.email}")
# Event mapper
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(
cqrs.NotificationEvent[UserCreatedPayload],
UserCreatedEventHandler
)
# Mediator factory
@functools.lru_cache(maxsize=1)
def mediator_factory() -> cqrs.EventMediator:
return bootstrap.bootstrap(
di_container=di.Container(),
events_mapper=events_mapper,
)
# Custom decoder
async def empty_message_decoder(
msg: kafka.KafkaMessage,
original_decoder: typing.Callable[
[kafka.KafkaMessage],
typing.Awaitable[types.DecodedMessage],
],
) -> types.DecodedMessage | None:
if not msg.body:
return None
return await original_decoder(msg)
# Kafka subscriber
@broker.subscriber(
"user_events",
group_id="user-service",
auto_commit=False,
auto_offset_reset="earliest",
value_deserializer=deserializers.JsonDeserializer(
model=cqrs.NotificationEvent[UserCreatedPayload],
),
decoder=empty_message_decoder,
)
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload]
| deserializers.DeserializeJsonError
| None,
msg: kafka.KafkaMessage,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
if isinstance(body, deserializers.DeserializeJsonError):
print(f"Deserialization error: {body}")
await msg.nack()
return
if body is not None:
await mediator.send(body)
await msg.ack()
if __name__ == "__main__":
asyncio.run(app.run())
```
### RabbitMQ Complete Example
```python
import functools
import logging
import di
import faststream
import pydantic
from faststream import rabbitmq
import cqrs
from cqrs import deserializers
from cqrs.bootstrap import events as bootstrap
logging.basicConfig(level=logging.INFO)
# Create RabbitMQ broker
broker = rabbitmq.RabbitBroker("amqp://guest:guest@localhost:5672/")
app = faststream.FastStream(broker)
# Event payload
class UserCreatedPayload(pydantic.BaseModel):
user_id: str
email: str
name: str
# Event handler (defined elsewhere)
class UserCreatedEventHandler(
cqrs.EventHandler[cqrs.NotificationEvent[UserCreatedPayload]]
):
async def handle(
self, event: cqrs.NotificationEvent[UserCreatedPayload]
) -> None:
print(f"User {event.payload.user_id} created: {event.payload.email}")
# Event mapper
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(
cqrs.NotificationEvent[UserCreatedPayload],
UserCreatedEventHandler
)
# Mediator factory
@functools.lru_cache(maxsize=1)
def mediator_factory() -> cqrs.EventMediator:
return bootstrap.bootstrap(
di_container=di.Container(),
events_mapper=events_mapper,
)
# RabbitMQ subscriber
@broker.subscriber(
"user_events",
queue="user_events_queue",
exchange="events",
routing_key="user.created",
durable=True,
parser=deserializers.JsonDeserializer(
model=cqrs.NotificationEvent[UserCreatedPayload],
),
)
async def handle_user_event(
body: cqrs.NotificationEvent[UserCreatedPayload]
| deserializers.DeserializeJsonError,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
if isinstance(body, deserializers.DeserializeJsonError):
print(f"Deserialization error: {body}")
return
await mediator.send(body)
if __name__ == "__main__":
import asyncio
asyncio.run(app.run())
```
## Best Practices
### 1. Use Singleton Mediator Factory
Always use `@functools.lru_cache` for mediator factory to avoid recreating mediators:
```python
@functools.lru_cache(maxsize=1)
def mediator_factory() -> cqrs.EventMediator:
return bootstrap.bootstrap(...)
```
### 2. Handle Deserialization Errors
Always check for `DeserializeJsonError` before processing:
```python
if isinstance(body, deserializers.DeserializeJsonError):
# Handle error
await msg.nack()
return
```
### 3. Manual Acknowledgment
Use manual acknowledgment for better control:
```python
@broker.subscriber(
"events",
auto_commit=False, # Kafka
ack=True, # RabbitMQ
)
async def handle_event(...):
try:
await mediator.send(body)
await msg.ack() # Acknowledge success
except Exception as e:
await msg.nack() # Retry on error
```
### 4. Error Handling
Wrap event processing in try-except:
```python
async def handle_event(...):
try:
await mediator.send(body)
await msg.ack()
except Exception as e:
print(f"Error: {e}")
await msg.nack() # Retry
```
### 5. Logging Configuration
Suppress verbose broker logging:
```python
logging.getLogger("aiokafka").setLevel(logging.ERROR) # Kafka
logging.getLogger("aio_pika").setLevel(logging.ERROR) # RabbitMQ
```
### 6. Empty Message Handling
Handle empty messages gracefully:
```python
async def empty_message_decoder(...):
if not msg.body:
return None
return await original_decoder(msg)
```
### 7. Multiple Event Types
Use separate subscribers for different event types:
```python
@broker.subscriber("user_events")
async def handle_user_event(...):
...
@broker.subscriber("order_events")
async def handle_order_event(...):
...
```
# Event Producing
## Overview
Event producing allows you to publish events to message brokers (Kafka, RabbitMQ) for asynchronous processing. The `python-cqrs` package provides message broker abstractions with **JSON as the default** codec. Protobuf is available as an opt-in per-event serializer — see [Protobuf Integration](https://mkdocs.python-cqrs.dev/latest/protobuf/index.md).
**Key Features:**
- **Multiple Brokers** — Support for Kafka and RabbitMQ
- **Serialization Formats** — JSON by default; opt-in Protobuf via `ProtobufEventSerializer` / `OutboxedEventMap`
- **Type Safety** — Full Pydantic v2 support for event payloads
- **Error Handling** — Built-in retry and error handling
Prerequisites
Understanding of [Event Handling](https://mkdocs.python-cqrs.dev/latest/event_handler/index.md) and [Bootstrap](https://mkdocs.python-cqrs.dev/latest/bootstrap/index.md) is required. Events are automatically published when command handlers emit them.
Related Topics
- [FastStream Integration](https://mkdocs.python-cqrs.dev/latest/faststream/index.md) — For consuming events from message brokers
- [Protobuf Integration](https://mkdocs.python-cqrs.dev/latest/protobuf/index.md) — For Protobuf serialization
- [Transaction Outbox](https://mkdocs.python-cqrs.dev/latest/outbox/index.md) — For reliable event delivery
## Basic Event Producing
Events are produced through the `EventEmitter` which is configured in the bootstrap process:
```python
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
from cqrs.message_brokers import devnull
def commands_mapper(mapper: cqrs.RequestMap) -> None:
mapper.bind(CreateUserCommand, CreateUserCommandHandler)
def events_mapper(mapper: cqrs.EventMap) -> None:
mapper.bind(UserCreatedEvent, UserCreatedEventHandler)
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
message_broker=devnull.DevnullMessageBroker(), # For testing
)
```
When a command handler emits events, they are automatically published to the configured message broker.
## Kafka Event Producing
### Basic Kafka Producer Setup
```python
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
from cqrs.message_brokers import kafka
from cqrs.adapters.kafka import KafkaProducerAdapter
# Create Kafka producer adapter
kafka_producer = KafkaProducerAdapter(
bootstrap_servers=["localhost:9092"],
client_id="my-app",
)
# Create message broker
kafka_broker = kafka.KafkaMessageBroker(
producer=kafka_producer,
aiokafka_log_level="ERROR",
)
# Bootstrap with Kafka broker
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
message_broker=kafka_broker,
)
```
### Kafka Producer with SSL/TLS
```python
import ssl
from cqrs.adapters.kafka import KafkaProducerAdapter
# Create SSL context
ssl_context = ssl.create_default_context()
kafka_producer = KafkaProducerAdapter(
bootstrap_servers=["kafka.example.com:9093"],
security_protocol="SASL_SSL",
sasl_mechanism="SCRAM-SHA-256",
sasl_plain_username="username",
sasl_plain_password="password",
ssl_context=ssl_context,
)
kafka_broker = kafka.KafkaMessageBroker(producer=kafka_producer)
```
### Kafka Producer Configuration
```python
kafka_producer = KafkaProducerAdapter(
bootstrap_servers=["localhost:9092"],
client_id="my-app",
acks="all", # Wait for all replicas
enable_idempotence=True, # Exactly-once semantics
max_in_flight_requests_per_connection=5,
retry_count=3,
retry_delay=1,
)
kafka_broker = kafka.KafkaMessageBroker(
producer=kafka_producer,
aiokafka_log_level="ERROR",
)
```
## RabbitMQ Event Producing
### Basic RabbitMQ Producer Setup
```python
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
from cqrs.message_brokers import amqp
from cqrs.adapters.amqp import AMQPPublisherAdapter
# Create AMQP publisher
amqp_publisher = AMQPPublisherAdapter(
dsn="amqp://user:password@localhost:5672/",
)
# Create message broker
amqp_broker = amqp.AMQPMessageBroker(
publisher=amqp_publisher,
exchange_name="events",
pika_log_level="ERROR",
)
# Bootstrap with RabbitMQ broker
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
message_broker=amqp_broker,
)
```
### RabbitMQ with Connection Pooling
```python
from cqrs.adapters.amqp import amqp_publisher_factory, amqp_connection_pool_factory
import aio_pika
# Create connection pool
connection_pool = amqp_connection_pool_factory(
url="amqp://user:password@localhost:5672/",
pool_size=10,
)
# Create publisher with pool
amqp_publisher = amqp_publisher_factory(
connection_pool=connection_pool,
)
amqp_broker = amqp.AMQPMessageBroker(
publisher=amqp_publisher,
exchange_name="events",
)
```
For opt-in Protobuf producing (per-event codec, `payload_bytes`, bootstrap `serializer=`), see [Protobuf Integration](https://mkdocs.python-cqrs.dev/latest/protobuf/index.md).
## Complete Examples
### Kafka JSON Example
```python
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
from cqrs.message_brokers import kafka
from cqrs.adapters.kafka import KafkaProducerAdapter
# Create Kafka producer
kafka_producer = KafkaProducerAdapter(
bootstrap_servers=["localhost:9092"],
client_id="my-app",
acks="all",
enable_idempotence=True,
)
# Create message broker
kafka_broker = kafka.KafkaMessageBroker(
producer=kafka_producer,
aiokafka_log_level="ERROR",
)
# Bootstrap
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
message_broker=kafka_broker,
)
```
### RabbitMQ JSON Example
```python
import di
import cqrs
from cqrs.bootstrap import requests as bootstrap
from cqrs.message_brokers import amqp
from cqrs.adapters.amqp import AMQPPublisherAdapter
# Create AMQP publisher
amqp_publisher = AMQPPublisherAdapter(
dsn="amqp://guest:guest@localhost:5672/",
)
# Create message broker
amqp_broker = amqp.AMQPMessageBroker(
publisher=amqp_publisher,
exchange_name="events",
pika_log_level="ERROR",
)
# Bootstrap
mediator = bootstrap.bootstrap(
di_container=di.Container(),
commands_mapper=commands_mapper,
domain_events_mapper=events_mapper,
message_broker=amqp_broker,
)
```
## Best Practices
### 1. Use Appropriate Serialization
Choose serialization format based on your needs:
- **JSON** — Default. Human-readable, easy to debug
- **Protobuf** — Opt-in per event — see [Protobuf Integration](https://mkdocs.python-cqrs.dev/latest/protobuf/index.md)
### 2. Configure Producer Settings
For Kafka, configure producer for reliability:
```python
kafka_producer = KafkaProducerAdapter(
bootstrap_servers=["localhost:9092"],
acks="all", # Wait for all replicas
enable_idempotence=True, # Exactly-once semantics
retry_count=3,
retry_delay=1,
)
```
### 3. Use Connection Pooling
For RabbitMQ, use connection pooling for better performance:
```python
connection_pool = amqp_connection_pool_factory(
url="amqp://user:password@localhost:5672/",
pool_size=10,
)
```
### 4. Error Handling
Handle producer errors appropriately:
```python
try:
await broker.send_message(message)
except Exception as e:
logger.error(f"Failed to publish event: {e}")
# Implement retry logic or dead letter queue
```
### 5. Protobuf Serialization
JSON stays the default. Register `ProtobufEventSerializer` only for events that need binary payloads — see [Protobuf Integration](https://mkdocs.python-cqrs.dev/latest/protobuf/index.md).
### 6. Logging
Suppress verbose broker logging:
```python
logging.getLogger("aiokafka").setLevel(logging.ERROR) # Kafka
logging.getLogger("aio_pika").setLevel(logging.ERROR) # RabbitMQ
```
### 7. Security
Use SSL/TLS for production:
```python
ssl_context = ssl.create_default_context()
kafka_producer = KafkaProducerAdapter(
bootstrap_servers=["kafka.example.com:9093"],
security_protocol="SASL_SSL",
sasl_mechanism="SCRAM-SHA-256",
ssl_context=ssl_context,
)
```
# Protobuf Integration
## Overview
JSON is the default wire format for notification events. Protobuf is an **opt-in codec**: register a `ProtobufEventSerializer` for specific event names, keep everything else on JSON, and leave Kafka/AMQP frames unchanged unless the codec sets a `content_type`.
| Layer | JSON (default) | Protobuf (opt-in) |
| ----------------------- | ------------------------------- | ----------------------------------------------------- |
| Outbox storage | `orjson.dumps(event.to_dict())` | `event.proto().SerializeToString()` |
| `Message.payload` | `event.to_dict()` | still `event.to_dict()` (structured / stubs) |
| `Message.payload_bytes` | JSON bytes | protobuf wire bytes |
| Broker headers | none | `event_name`, `message_id` when `content_type` is set |
| Consume | `JsonDeserializer` | `ProtobufDeserializer` |
Prerequisites
Familiarity with [Transactional Outbox](https://mkdocs.python-cqrs.dev/latest/outbox/index.md) and [Event Producing](https://mkdocs.python-cqrs.dev/latest/event_producing/index.md) helps. Install the extra with `pip install "python-cqrs[protobuf]"`.
When to use
Use Protobuf for compact binary payloads and schema evolution across services. For most apps JSON is enough — do not switch globally.
## Event contract
Events that use Protobuf must implement:
- `proto()` — returns a generated protobuf message
- `from_proto(cls, proto_msg)` — rebuilds the event from that message
```python
import uuid
from datetime import datetime
import pydantic
import cqrs
from app.generated import user_joined_pb2 # generated from .proto
class UserJoinedPayload(pydantic.BaseModel, frozen=True):
user_id: str
meeting_id: str
class UserJoinedNotificationEvent(cqrs.NotificationEvent[UserJoinedPayload]):
event_name: str = "user_joined"
def proto(self):
msg = user_joined_pb2.UserJoinedNotification()
msg.event_id = str(self.event_id)
msg.event_timestamp = self.event_timestamp.isoformat()
msg.event_name = self.event_name
msg.payload.user_id = self.payload.user_id
msg.payload.meeting_id = self.payload.meeting_id
return msg
@classmethod
def from_proto(cls, proto_msg):
return cls(
event_id=uuid.UUID(proto_msg.event_id),
event_timestamp=datetime.fromisoformat(proto_msg.event_timestamp),
event_name=proto_msg.event_name,
topic="user_notification_events",
payload=UserJoinedPayload(
user_id=proto_msg.payload.user_id,
meeting_id=proto_msg.payload.meeting_id,
),
)
```
Schema assets used in examples live in [examples/proto/](https://github.com/vadikko2/python-cqrs/tree/master/examples/proto).
## Codecs
### `JsonEventSerializer`
Default codec. `content_type` is `None`, so brokers do not add MIME headers.
```python
from cqrs.serializers import JsonEventSerializer
codec = JsonEventSerializer()
wire = codec.serialize(event) # orjson bytes of event.to_dict()
restored = codec.deserialize(wire, type(event))
```
### `ProtobufEventSerializer`
Maps event classes to generated protobuf message types. Duck-typed — does not import `google.protobuf` until you call `proto()` / `FromString`.
```python
import cqrs
proto_codec = cqrs.ProtobufEventSerializer(
{UserJoinedNotificationEvent: user_joined_pb2.UserJoinedNotification},
)
```
`content_type` is `"application/x-protobuf"`. When that value is set on a `Message`, Kafka/AMQP also receive headers `event_name` and `message_id`.
### Protocols
- `EventSerializer` — emit-only (`serialize`, `content_type_for`)
- `EventCodec` — serialize + `deserialize` (outbox repository)
Implement these if you need a custom format.
## OutboxedEventMap and per-event codecs
`OutboxedEventMap` binds an event name to a type and an optional codec.
```python
import cqrs
events = cqrs.OutboxedEventMap() # isolated registry (recommended)
events.register(
"user_joined",
UserJoinedNotificationEvent,
serializer=cqrs.ProtobufEventSerializer(
{UserJoinedNotificationEvent: user_joined_pb2.UserJoinedNotification},
),
)
# Other events can stay on the repository default (JSON):
events.register("user_left", UserLeftNotificationEvent)
```
Process-global vs isolated
Class-level `OutboxedEventMap.register(...)` mutates a process singleton (legacy behaviour). Prefer `OutboxedEventMap()` when tests or apps need isolation.
Pass the map into the repository and optionally use `as_serializer()` for the emitter:
```python
repository = cqrs.SqlAlchemyOutboxedEventRepository(
session,
serializer=cqrs.JsonEventSerializer(), # fallback when register() has no codec
event_map=events,
)
emitter = cqrs.EventEmitter(
event_map=domain_event_map,
container=container,
message_broker=broker,
serializer=events.as_serializer(default=cqrs.JsonEventSerializer()),
)
```
`OutboxedEventMap.as_serializer(default=...)` dispatches by `event.event_name`: registered codec first, otherwise the default.
Bootstrap accepts the same codec:
```python
from cqrs.bootstrap import requests as bootstrap
mediator = bootstrap.bootstrap(
di_container=container,
commands_mapper=commands_mapper,
domain_events_mapper=domain_events_mapper,
message_broker=broker,
serializer=events.as_serializer(),
)
```
## Message wire shape
Emitters and the outbox producer always set both fields:
| Field | Role |
| -------------------------- | ------------------------------------------------------------------------------- |
| `payload` | Structured dict (`event.to_dict()`) — stubs, `Message.to_dict()`, Devnull |
| `payload_bytes` | Codec wire bytes — what Kafka/AMQP actually publish |
| `content_type` / `headers` | Set only when the codec returns a MIME type (Protobuf); JSON leaves them `None` |
Brokers prefer `payload_bytes` via `message_wire_bytes()`. Ready-made `bytes` are not JSON-encoded again (`passthrough_value_serializer` on the Kafka adapter).
## Producing from outbox
Flow is unchanged: handler writes to outbox in the same DB transaction; a publisher drains rows with `EventProducer`. With a Protobuf codec registered, rows store protobuf bytes; on read, `payload_bytes` and `content_type` are restored onto `OutboxedEvent` so publish does not re-serialize.
```python
producer = cqrs.EventProducer(message_broker=broker, repository=repository)
async for batch in producer.event_batch_generator():
for outboxed in batch:
await producer.send_message(outboxed)
await producer.repository.commit()
```
## Consuming with FastStream
Use `ProtobufDeserializer` as the FastStream value deserializer. It calls `from_proto` and returns `DeserializeProtobufError` on failure (same pattern as `JsonDeserializer`).
```python
import faststream
from faststream import kafka
import cqrs
from cqrs.deserializers import DeserializeProtobufError, ProtobufDeserializer
from cqrs.bootstrap import events as bootstrap
broker = kafka.KafkaBroker(bootstrap_servers=["localhost:9092"])
app = faststream.FastStream(broker)
def mediator_factory() -> cqrs.EventMediator:
return bootstrap.bootstrap(
di_container=di.Container(),
events_mapper=events_mapper,
)
@broker.subscriber(
"user_notification_events",
group_id="protobuf-consumers",
auto_commit=False,
value_deserializer=ProtobufDeserializer(
UserJoinedNotificationEvent,
user_joined_pb2.UserJoinedNotification,
),
)
async def handle_user_joined(
body: UserJoinedNotificationEvent | DeserializeProtobufError | None,
msg: kafka.KafkaMessage,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
if isinstance(body, DeserializeProtobufError):
await msg.nack()
return
if body is None:
await msg.ack()
return
await mediator.send(body)
await msg.ack()
```
For JSON consumers see [FastStream Integration](https://mkdocs.python-cqrs.dev/latest/faststream/index.md).
## Complete local example
Produce + consume without Kafka (in-memory broker, isolated map):
```bash
pip install -e ".[examples]"
python examples/outbox/protobuf_outbox.py
```
Source: [examples/outbox/protobuf_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/outbox/protobuf_outbox.py).
## Best practices
1. **Opt in per event** — register `serializer=` only for events that need Protobuf; leave the repository default as JSON.
1. **Isolated maps** — use `OutboxedEventMap()` in apps and tests; avoid the process-global singleton when possible.
1. **Keep `proto()` / `from_proto()` in sync** — every field you serialize must round-trip.
1. **Handle `DeserializeProtobufError`** — nack / DLQ on the consumer; do not assume every body is a valid event.
1. **Do not double-encode** — pass wire bytes through `payload_bytes`; do not wrap them in another JSON serializer on the producer.
1. **Headers are MIME-gated** — JSON frames stay header-free; Protobuf sets `content_type` and then `event_name` / `message_id`.
## Related
- [Transactional Outbox](https://mkdocs.python-cqrs.dev/latest/outbox/index.md) — reliable publish path that stores codec bytes
- [Event Producing](https://mkdocs.python-cqrs.dev/latest/event_producing/index.md) — brokers and `EventEmitter`
- [FastStream Integration](https://mkdocs.python-cqrs.dev/latest/faststream/index.md) — Kafka / RabbitMQ consumers