Skip to main content

Python CQRS

CQRS and event-driven architecture for Python services, from a single API to distributed systems.

Python Versions PyPI version Total downloads Downloads per month Coverage Included in Awesome FastAPI

Documentation · Examples · Changelog

As a service grows, business logic spreads across routes, consumers, and workers. python-cqrs puts each use case in one handler behind a mediator. Routes and consumers only send requests, and side effects become events with their own handlers.

Once services talk to each other, a database write and a broker message are no longer atomic. The outbox and inbox keep messages from being lost or handled twice, and a saga undoes completed steps when a later one fails.

Dataclasses or Pydantic · SQLAlchemy or SQLModel · Kafka or RabbitMQ · FastAPI and FastStream · Python 3.10+

Installation

pip install python-cqrs
pip install "python-cqrs[pydantic,sqlalchemy,kafka]"  # add what you use

Extras: pydantic, sqlalchemy, sqlmodel, kafka, rabbit, protobuf, aiobreaker, dishka, testing. Details: installation. From 4.x: migration guide.

Quick start

import di
import cqrs
from cqrs.bootstrap import requests as bootstrap


class OrderCreated(cqrs.Response):
    order_id: str


class CreateOrder(cqrs.Request[OrderCreated]):
    sku: str
    amount: float


class CreateOrderHandler(cqrs.RequestHandler[CreateOrder]):
    async def handle(self, request: CreateOrder):
        return OrderCreated(order_id=f"ord-{request.sku}")


def commands_mapper(mapper: cqrs.RequestMap) -> None:
    mapper.bind(CreateOrder, CreateOrderHandler)


mediator = bootstrap.bootstrap(di_container=di.Container(), commands_mapper=commands_mapper)
result = await mediator.send(CreateOrder(sku="book", amount=99.99))  # OrderCreated(order_id="ord-book")

result is typed as OrderCreated. Queries use the same shape. Handler dependencies come from di, dependency-injector, or dishka. Request handlers · Bootstrap · Dependency injection

Saga

Paying for an order touches the warehouse service and the payment service, and no transaction spans both. If the charge fails, the stock stays reserved. A saga runs the steps in order and, on failure, calls compensate on the completed ones in reverse.

import dataclasses
from cqrs.bootstrap import saga as saga_bootstrap
from cqrs.saga.models import SagaContext


@dataclasses.dataclass
class OrderContext(SagaContext):
    order_id: str
    amount: float
    reservation_id: str | None = None


class ReserveStock(cqrs.SagaStepHandler[OrderContext, None]):
    def __init__(self, inventory: InventoryClient) -> None:
        self._inventory = inventory

    async def act(self, context: OrderContext):
        context.reservation_id = await self._inventory.reserve(context.order_id)
        return self._generate_step_result(None)

    async def compensate(self, context: OrderContext) -> None:
        await self._inventory.release(context.reservation_id)


class ChargeCard(cqrs.SagaStepHandler[OrderContext, None]):
    def __init__(self, payments: PaymentClient) -> None:
        self._payments = payments

    async def act(self, context: OrderContext):
        await self._payments.charge(context.order_id, context.amount)
        return self._generate_step_result(None)

    async def compensate(self, context: OrderContext) -> None:
        await self._payments.refund(context.order_id)


class OrderSaga(cqrs.Saga[OrderContext]):
    steps = [ReserveStock, ChargeCard]


def sagas_mapper(mapper: cqrs.SagaMap) -> None:
    mapper.bind(OrderContext, OrderSaga)


mediator = saga_bootstrap.bootstrap(di_container=di.Container(), sagas_mapper=sagas_mapper)

# if ChargeCard raises, ReserveStock.compensate releases the stock
async for step in mediator.execute(OrderContext(order_id="ord-1", amount=99.99)):
    print(step.step_type.__name__)

Storage, recovery after a crash, and fallback steps: Saga.

Outbox and inbox

Outbox

Commit the order, then publish to Kafka: a crash between the two loses the event. The outbox writes the event in the same transaction as the order. A publisher sends it later, at least once.

class OrderPlaced(cqrs.NotificationEvent):
    event_name: str = "order_placed"
    topic: str = "orders"


class CreateOrderHandler(cqrs.RequestHandler[CreateOrder]):
    def __init__(self, outbox: cqrs.OutboxedEventRepository) -> None:
        self._outbox = outbox

    async def handle(self, request: CreateOrder):
        order_id = f"ord-{request.sku}"
        # write the order row through the same session here
        self._outbox.add(OrderPlaced(payload={"order_id": order_id}))
        await self._outbox.commit()
        return OrderCreated(order_id=order_id)

Outbox · Database support · examples/outbox/

Inbox

The broker redelivers, and a consumer can crash halfway through a handler. The FastStream subscriber only stores the message. A repeated payment_id is skipped.

class ConfirmPayment(cqrs.Request[None]):
    order_id: str


@broker.subscriber("payments")
async def on_payment(
    body: dict,
    inbox: cqrs.InboxRepository = Depends(inbox_repository),  # commits after return, then ack
) -> None:
    await inbox.add([(body["payment_id"], ConfirmPayment(order_id=body["order_id"]))])

A worker runs stored rows through the mediator. One commit saves the handler's changes and the processed status.

processor = cqrs.InboxProcessor(inbox, message_map, request_mediator=mediator)
for row in await inbox.get_many():
    await processor.process(row)
await session.commit()

Inbox · examples/inbox/

Domain events

A placed order also updates a read model and sends an email. In the command handler, that couples unrelated work. Instead, the handler emits an event, and each reaction is its own handler.

class OrderPlaced(cqrs.DomainEvent):
    order_id: str


class CreateOrderHandler(cqrs.RequestHandler[CreateOrder]):
    def __init__(self) -> None:
        self._events: list[cqrs.Event] = []

    @property
    def events(self) -> list[cqrs.Event]:
        return self._events

    async def handle(self, request: CreateOrder):
        order_id = f"ord-{request.sku}"
        self._events.append(OrderPlaced(order_id=order_id))
        return OrderCreated(order_id=order_id)


class UpdateOrderList(cqrs.EventHandler[OrderPlaced]):
    async def handle(self, event: OrderPlaced) -> None: ...


class SendConfirmation(cqrs.EventHandler[OrderPlaced]):
    async def handle(self, event: OrderPlaced) -> None: ...


def domain_events_mapper(mapper: cqrs.EventMap) -> None:
    mapper.bind(OrderPlaced, UpdateOrderList)
    mapper.bind(OrderPlaced, SendConfirmation)

Pass domain_events_mapper to bootstrap. Event handlers run after the command, in parallel when enabled. Events · Parallel processing · Scoped dependencies

FastAPI

The route parses HTTP and sends the request. The handler does not import FastAPI.

@app.post("/orders")
async def create_order(
    command: CreateOrder,
    mediator: cqrs.RequestMediator = Depends(mediator_factory),
) -> dict[str, str]:
    result = await mediator.send(command)
    return {"order_id": result.order_id}

FastStream consumers and SSE streaming work the same way. FastAPI · FastStream · SSE streaming

Testing

Mocks show that a method was called, not what the service did. test_harness runs the real mediators and records what was handled, published, and stored.

from cqrs.testing import test_harness


async def test_create_order() -> None:
    # Arrange
    async with test_harness(di.Container(), commands_mapper=commands_mapper) as harness:

        # Act
        result = await harness.mediator.send(CreateOrder(sku="book", amount=99.99))

        # Assert
        assert result.order_id == "ord-book"
        harness.requests.assert_contains(CreateOrder)
        harness.invocations.assert_entered(CreateOrderHandler)
        harness.published.assert_empty()

The same journals cover domain events, the outbox, and saga compensation. Testing · examples/testing/

When NOT to use python-cqrs

CQRS, Outbox, and Saga are tools for specific problems — not a default for every service.

  • Prefer a local DB transaction for single-service CRUD (one database, no broker, no compensation).
  • Use Transactional Outbox when you dual-write to a database and a message broker. Commit the business row and the outbox row together; a publisher drains the outbox later.
  • Use Saga when a multi-step flow crosses service/database boundaries and a later step must be compensated if it fails.

Do not add a Saga for a single UPDATE in one service, and do not use a Saga as a substitute for Outbox. Full write-up: When NOT to use python-cqrs.

Documentation

Full guides: mkdocs.python-cqrs.dev. Also there:

Agent skills

Skills tell a coding agent which feature fits the task. Install from your service's root:

npx skills add pypatterns/python-cqrs --skill cqrs-setup -y
npx skills add pypatterns/python-cqrs --skill cqrs-eda -y
npx skills add pypatterns/python-cqrs --skill cqrs-saga -y
npx skills add pypatterns/python-cqrs --skill cqrs-streaming -y
npx skills add pypatterns/python-cqrs --skill cqrs-cor -y
npx skills add pypatterns/python-cqrs --skill cqrs-testing -y

Also copy templates/AGENTS.md into the application. Agent skills · Rules for agents

Contributing

pip install -e ".[dev]"
pytest -c ./tests/pytest-config.ini ./tests/unit
pre-commit install

Details: CONTRIBUTING.md. Vulnerabilities: SECURITY.md.

License

MIT

Metadata

Release files for python-cqrs 5.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for python-cqrs 5.5.0
File Size Uploaded Provenance Actions
python_cqrs-5.5.0.tar.gz 144.1 kB Without provenance Details for python_cqrs-5.5.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for python-cqrs 5.5.0
File Interpreter ABI Platform Actions
python_cqrs-5.5.0-py3-none-any.whl Python 3 none any Details for python_cqrs-5.5.0-py3-none-any.whl

Total release size: 334.5 kB

Release files / python_cqrs-5.5.0.tar.gz

Download URL python_cqrs-5.5.0.tar.gz
Size 144.1 kB
Tags Source
SHA-256 checksum
How to use checksums
bb51c8470ef0e10c33fbb165e9a77d5c4bfa5f628f0cfe7765717473277cfecb
BLAKE2b-256 checksum
How to use checksums
71d7c6534c6f004ae00570ede7fb978f67ff75785cbef668c843ce2dece40382
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.9

Release files / python_cqrs-5.5.0-py3-none-any.whl

Download URL python_cqrs-5.5.0-py3-none-any.whl
Size 190.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4685dcdbf1129dc8d67988bf23f07456868595d06ead974488194eba52d13091
BLAKE2b-256 checksum
How to use checksums
88769ea99e0b31d184ee413457f599d5c042226f7903581c640f824135c3b184
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.9

Security for python-cqrs 5.5.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

5.5.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page