Skip to main content

Async CQRS command/query bus and mediator for Python — handler auto-discovery, type-based dependency injection, middleware, and an event bus.

Project description

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.11+, 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).

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.11+. MIT licensed.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cqrs_bus-0.2.0.tar.gz (23.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cqrs_bus-0.2.0-py3-none-any.whl (20.5 kB view details)

Uploaded Python 3

File details

Details for the file cqrs_bus-0.2.0.tar.gz.

File metadata

  • Download URL: cqrs_bus-0.2.0.tar.gz
  • Upload date:
  • Size: 23.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for cqrs_bus-0.2.0.tar.gz
Algorithm Hash digest
SHA256 a358ed8c58782b9cf0345798758a313f055cfa50dc32bf5f49ffa8c978267a31
MD5 5bc801d7bd492c270588c4a340f75b84
BLAKE2b-256 0dc0ea0507dc533a06031078aed152eb02b9906c2abf6b45d40930f258a55cde

See more details on using hashes here.

Provenance

The following attestation bundles were made for cqrs_bus-0.2.0.tar.gz:

Publisher: release.yml on john-athan/cqrs-bus

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file cqrs_bus-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: cqrs_bus-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 20.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for cqrs_bus-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 830fa306b393260a7121939f76f1124601fa0219bfa84fac0c6c3ae753c21722
MD5 4080cec42b7a1f132a46af6bf0dacdfc
BLAKE2b-256 1bf9be517ef31b342d4bf28ca274df9a03e25745cd200b7181852a3ea1a56c23

See more details on using hashes here.

Provenance

The following attestation bundles were made for cqrs_bus-0.2.0-py3-none-any.whl:

Publisher: release.yml on john-athan/cqrs-bus

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page