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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a358ed8c58782b9cf0345798758a313f055cfa50dc32bf5f49ffa8c978267a31
|
|
| MD5 |
5bc801d7bd492c270588c4a340f75b84
|
|
| BLAKE2b-256 |
0dc0ea0507dc533a06031078aed152eb02b9906c2abf6b45d40930f258a55cde
|
Provenance
The following attestation bundles were made for cqrs_bus-0.2.0.tar.gz:
Publisher:
release.yml on john-athan/cqrs-bus
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cqrs_bus-0.2.0.tar.gz -
Subject digest:
a358ed8c58782b9cf0345798758a313f055cfa50dc32bf5f49ffa8c978267a31 - Sigstore transparency entry: 1901039801
- Sigstore integration time:
-
Permalink:
john-athan/cqrs-bus@246a7e6d46565dd51f715f5854f5fdeaacaa8db9 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/john-athan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@246a7e6d46565dd51f715f5854f5fdeaacaa8db9 -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
830fa306b393260a7121939f76f1124601fa0219bfa84fac0c6c3ae753c21722
|
|
| MD5 |
4080cec42b7a1f132a46af6bf0dacdfc
|
|
| BLAKE2b-256 |
1bf9be517ef31b342d4bf28ca274df9a03e25745cd200b7181852a3ea1a56c23
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cqrs_bus-0.2.0-py3-none-any.whl -
Subject digest:
830fa306b393260a7121939f76f1124601fa0219bfa84fac0c6c3ae753c21722 - Sigstore transparency entry: 1901039907
- Sigstore integration time:
-
Permalink:
john-athan/cqrs-bus@246a7e6d46565dd51f715f5854f5fdeaacaa8db9 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/john-athan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@246a7e6d46565dd51f715f5854f5fdeaacaa8db9 -
Trigger Event:
push
-
Statement type: