Skip to main content

cqrs-bus

An async CQRS command/query bus and mediator for Python with handler auto-discovery — think MediatR, but for async Python with zero runtime dependencies.

Your app dispatches commands and queries without knowing what handles them. Handlers live in their own modules, get picked up automatically at startup, and their dependencies are resolved from __init__ type annotations. No decorators, no registry to hand-maintain.

Features

  • Command / query buses — one message, exactly one handler, statically typed end to end.
  • Event bus — one event, many subscribers, run concurrently and in isolation.
  • Handler auto-discovery — point it at a package; it finds every handler.
  • Type-based dependency injection — deps resolved from __init__ annotations, validated at build time.
  • Middleware pipeline — onion-style cross-cutting behavior (transactions, retries, auth, timing).
  • Observability — structured logs + optional Prometheus metrics, no setup.
  • Zero runtime deps, Python 3.14+, stdlib only (Prometheus is opt-in).

How it works

A command/query flows through the middleware pipeline to its single handler. An event fans out to every subscriber:

flowchart LR
    App -->|dispatch CreateOrder| CB[CommandBus]
    CB -->|middleware pipeline| H[CreateOrderHandler]
    H --> R[order_id]

    App2[App] -->|publish OrderPlaced| EB[EventBus]
    EB --> H1[SendConfirmationEmail]
    EB --> H2[UpdateAnalytics]

Middleware wraps each dispatch outermost-first — first added is first in, last out:

flowchart LR
    M[message] --> T[timing]
    T --> TX[transactional]
    TX --> H[handler]
    H -.result.-> TX
    TX -.result.-> T

At startup, build_buses scans your handlers package, reads each handler's __init__ annotations, injects matching dependencies, and registers live buses:

sequenceDiagram
    participant S as Startup
    participant D as HandlerDiscovery
    participant R as DependencyResolver
    participant B as Bus
    S->>D: scan "myapp.handlers"
    D->>R: read __init__ annotations
    R-->>S: {param_name: type}
    S->>B: register(msg_type, handler(**deps))

Quick start

Command and Query are plain marker base classes — they carry no fields of their own. Add data however you like (@dataclass, Pydantic, attrs). Optionally parameterize the message with its result type for end-to-end typing:

from dataclasses import dataclass

from cqrs_bus import Command, CommandHandler, CommandBus

@dataclass
class CreateOrder(Command[str]):        # resolves to a str
    customer_id: str
    total: float

class CreateOrderHandler(CommandHandler[CreateOrder, str]):
    def __init__(self, db: Database):   # dep injected by type
        self.db = db

    async def handle(self, command: CreateOrder) -> str:
        return await self.db.insert_order(command.customer_id, command.total)

bus = CommandBus()
bus.register(CreateOrder, CreateOrderHandler(db=my_db))

order_id = await bus.dispatch(CreateOrder(customer_id="c-123", total=49.99))  # inferred: str

Queries are identical with Query / QueryHandler / QueryBus. The result-type parameter is optional; an unparameterized Command dispatches to Any.

Events

A command has exactly one handler; an event can have many — or none. Use EventBus to publish domain events (something that happened) to every subscriber:

from dataclasses import dataclass

from cqrs_bus import Event, EventHandler, EventBus

@dataclass
class OrderPlaced(Event):
    order_id: str

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

bus = EventBus()
bus.subscribe(OrderPlaced, SendConfirmationEmail())

await bus.publish(OrderPlaced(order_id="o-1"))   # every subscriber runs
bus.handler_count(OrderPlaced)                   # -> 1

Subscribers run concurrently and in isolation: one failing handler is logged (and counted in metrics) but never aborts the others, and publish itself does not raise. Publishing with no subscribers is a no-op.

Auto-discovery

With more than a handful of handlers, let discovery find them. Lay them out in commands/, queries/, and events/ packages:

myapp/handlers/
  commands/create_order.py   # CreateOrderHandler
  queries/get_order.py       # GetOrderHandler
  events/order_placed.py     # one or more OrderPlaced subscribers

build_buses is the one-liner — point it at the package, hand it the shared dependencies, get ready-to-use buses:

from cqrs_bus import build_buses

buses = build_buses(
    "myapp.handlers",
    dependencies={Database: my_db},
    strict=False,        # True -> discovery errors raise instead of skip+log
    on_dispatch=my_hook, # optional telemetry callback, wired into all three buses
)

order_id = await buses.command_bus.dispatch(CreateOrder(customer_id="c-123", total=49.99))
order    = await buses.query_bus.dispatch(GetOrder(order_id=order_id))
await buses.event_bus.publish(OrderPlaced(order_id=order_id))

Dependencies are injected by type annotation: a handler whose __init__ takes db: Database receives whatever you registered under the Database key. Optional[T] / T | None are unwrapped, a registered base type satisfies a subclass annotation, and a parameter with a default is skipped when unresolved. Missing dependencies raise at build time, not on first dispatch.

Lower-level building blocks (bring your own DI container)

Drop down to HandlerDiscovery (which finds handlers) and the bus register methods to control instantiation yourself:

from cqrs_bus import HandlerDiscovery, CommandBus

registry = HandlerDiscovery(base_package="myapp.handlers").discover_all_handlers()

command_bus = CommandBus()
for meta in registry.get_all_command_handlers():
    deps = {name: my_container.resolve(dep) for name, dep in meta.dependencies.items()}
    command_bus.register(meta.command_or_query_type, meta.handler_class(**deps))

DependencyResolver inspects a handler class and returns a {param_name: type} dict you can feed to any DI container or factory. get_all_query_handlers() and get_all_event_handlers() work the same way.

Middleware

Wrap every dispatch with cross-cutting behavior. A middleware receives the message and a call_next continuation; it can inspect or replace the message, short-circuit, transform the result, or catch errors:

async def transactional(message, call_next):
    async with db.transaction():
        return await call_next(message)

bus = CommandBus(middleware=[transactional])   # or bus.add_middleware(...)

Middlewares run outermost-first (see diagram above). Both CommandBus and QueryBus support them via the shared MessageBus base; the on_dispatch callback and metrics measure the whole pipeline, middleware included.

Observability

Structured logs on every dispatch, carrying a per-dispatch UUID and the message name as extras (command_id/command_type, query_id/query_type, event_id/event_type):

  • DEBUG — normal dispatch.
  • INFO — slow command (> 1s); CommandBus only.
  • ERROR — handler failure, with full traceback.

If prometheus-client is installed, metrics are registered on import — no setup:

Bus executions duration errors
command command_executions_total command_duration_seconds command_errors_total
query query_executions_total query_duration_seconds query_errors_total
event event_publications_total event_duration_seconds event_handler_errors_total

Pass an on_dispatch=callback to any bus constructor for your own telemetry: (name: str, duration: float, error: Exception | None). It runs after the handler's own outcome is decided, and an exception it raises is logged, not propagated: a broken callback can't turn a successful dispatch into a raised error or break EventBus.publish's no-raise contract.

Errors

All discovery/wiring errors subclass HandlerDiscoveryError:

Exception Raised when
MissingDependencyError a handler dep isn't in the map, or the handler won't construct
DuplicateHandlerError two handlers target the same command or query
InvalidHandlerError a discovered handler has an invalid shape
HandlerDiscoveryError base class / other discovery failure

Install & requirements

pip install cqrs-bus                 # core, zero runtime deps
pip install "cqrs-bus[prometheus]"  # with Prometheus metrics

Python 3.14+. MIT licensed.

Metadata

Release files for cqrs-bus 0.3.1

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

Source distribution (sdist)

Source distribution for cqrs-bus 0.3.1
File Size Uploaded
cqrs_bus-0.3.1.tar.gz 31.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for cqrs-bus 0.3.1
File Interpreter ABI Platform
cqrs_bus-0.3.1-py3-none-any.whl Python 3 none any Details

Total release size: 52.6 kB

Release files / cqrs_bus-0.3.1.tar.gz

Download URL cqrs_bus-0.3.1.tar.gz
Size 31.7 kB
Tags Source
SHA-256 checksum
How to use checksums
24728e0188e4c194f0714afaf0441b0ce02047dba1bad62a033769211e12e962
BLAKE2b-256 checksum
How to use checksums
d2258c4e48645a197e5bfa907ec42ac00de54b4d8535f937be0830424681ddef
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 13, 2026.

Transparency log

Release files / cqrs_bus-0.3.1-py3-none-any.whl

Download URL cqrs_bus-0.3.1-py3-none-any.whl
Size 20.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fa5ad74210b80db87ffcc917b5562999030800e7ebf0568c5a1cb9db2a37c790
BLAKE2b-256 checksum
How to use checksums
32d3af515b15eaae384809bc6b163ac672cc83a2c12c603d31c60a11b88f55c5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 13, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.1 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

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