Documentation Index
Fetch the complete documentation index at: https://mintlify.com/Indroic/HexCore/llms.txt
Use this file to discover all available pages before exploring further.
Command Query Responsibility Segregation (CQRS) is an architectural pattern that separates the models used for writing (commands) from those used for reading (queries). HexCore v2 implements CQRS natively with three independent buses, a central handler registry, and a middleware pipeline — giving you clear boundaries between side-effecting operations and pure reads, all wired with zero framework lock-in. This page covers every bus port, explains why queries are treated differently from commands, and shows you how to register handlers and dispatch messages in real code.
The three buses
HexCore defines three abstract bus ports in hexcore/domain/cqrs/buses.py. Each is an independent abc.ABC so that you can substitute any implementation — in-memory, Redis, RabbitMQ, or your own — without changing application-layer code.
AbstractCommandBus — write side
A command expresses an intent to change state. The bus routes it to exactly one handler, executes any middleware, and returns the handler’s result.
# hexcore/domain/cqrs/buses.py
class AbstractCommandBus(abc.ABC):
@abstractmethod
async def dispatch(self, command: "Command") -> Any:
"""
Routes a command to its registered handler.
Raises HandlerNotFoundError if no handler is registered.
"""
raise NotImplementedError
AbstractQueryBus — read side
A query expresses a request for information with no side effects. The bus routes it to exactly one handler, which returns a typed result. Queries never use background execution — reads are always synchronous relative to the caller.
# hexcore/domain/cqrs/buses.py
class AbstractQueryBus(abc.ABC):
@abstractmethod
async def ask(self, query: "Query[TResult]") -> TResult:
"""
Routes a query to its registered handler and returns the result.
Raises HandlerNotFoundError if no handler is registered.
"""
raise NotImplementedError
AbstractEventBus — domain events
An event bus distributes domain events to zero or more subscribed handlers. Unlike command and query buses, the event bus supports fan-out: multiple parts of the system can react to the same event independently.
# hexcore/domain/cqrs/buses.py
class AbstractEventBus(abc.ABC):
@abstractmethod
async def publish(self, event: DomainEvent) -> None:
"""Publishes an event to all subscribed handlers."""
raise NotImplementedError
@abstractmethod
def subscribe(
self,
event_type: type[DomainEvent],
handler: Callable[[DomainEvent], Awaitable[None]],
) -> None:
"""Registers a handler for the given event type."""
raise NotImplementedError
Commands in depth
Defining a command
Command is a frozen Pydantic model. Subclass it and add the fields your use-case requires. Every command automatically gets a command_id (UUID) and timestamp (UTC datetime).
from hexcore.domain.cqrs.commands import Command
class CreateOrderCommand(Command):
customer_id: UUID
product_ids: list[UUID]
shipping_address: str
Writing a command handler
AbstractCommandHandler[TCommand, TResult] declares a single abstract handle method. Return None for fire-and-forget commands.
from hexcore.domain.cqrs.handlers import AbstractCommandHandler
from hexcore.domain.uow import IUnitOfWork
class CreateOrderHandler(AbstractCommandHandler[CreateOrderCommand, OrderDTO]):
def __init__(self, uow: IUnitOfWork) -> None:
self.uow = uow
async def handle(self, command: CreateOrderCommand) -> OrderDTO:
async with self.uow:
repo = self.uow.repositories["orders"]
order = Order(
customer_id=command.customer_id,
product_ids=command.product_ids,
shipping_address=command.shipping_address,
)
order.register_event(OrderCreatedEvent(entity_id=order.id, entity_data=order))
await repo.save(order)
await self.uow.commit()
return OrderDTO.model_validate(order)
Queries in depth
Defining a query
Query[TResult] is a frozen Pydantic model. The generic type parameter documents the return type so that the query bus and type checkers can infer the result.
from hexcore.domain.cqrs.queries import Query
class GetOrderByIdQuery(Query[OrderDTO]):
order_id: UUID
class ListOrdersQuery(Query[list[OrderDTO]]):
customer_id: UUID
limit: int = 20
offset: int = 0
Writing a query handler
from hexcore.domain.cqrs.handlers import AbstractQueryHandler
class GetOrderByIdHandler(AbstractQueryHandler[GetOrderByIdQuery, OrderDTO]):
def __init__(self, repository: IBaseRepository[Order]) -> None:
self.repository = repository
async def handle(self, query: GetOrderByIdQuery) -> OrderDTO:
order = await self.repository.get_active_by_id(query.order_id)
return OrderDTO.model_validate(order)
Queries must never mutate state. Do not call repo.save(), repo.delete(), or uow.commit() inside a query handler. This is the single rule that makes CQRS work.
Registering handlers
HandlerRegistry maps command and query types to their handlers. It supports both eager (direct instance) and lazy (factory function) registration:
from hexcore.application.cqrs.registry import HandlerRegistry
registry = HandlerRegistry()
# Eager registration — handler is instantiated immediately
registry.register_command_handler(CreateOrderCommand, CreateOrderHandler(uow=uow))
registry.register_query_handler(GetOrderByIdQuery, GetOrderByIdHandler(repository=repo))
# Lazy registration — factory is called on first dispatch (useful with DI containers)
registry.register_command_handler(
CreateOrderCommand,
lambda: container.get(CreateOrderHandler),
)
Registries support a fluent API — register_command_handler returns self:
registry = (
HandlerRegistry()
.register_command_handler(CreateOrderCommand, CreateOrderHandler(uow))
.register_command_handler(CancelOrderCommand, CancelOrderHandler(uow))
.register_query_handler(GetOrderByIdQuery, GetOrderByIdHandler(repo))
)
In-memory buses (default implementation)
For development, testing, and simple deployments, HexCore ships three in-memory bus implementations in hexcore.application.cqrs.in_memory_buses.
InMemoryCommandBus
from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus
from hexcore.application.cqrs.registry import HandlerRegistry
registry = HandlerRegistry()
registry.register_command_handler(CreateOrderCommand, CreateOrderHandler(uow))
bus = InMemoryCommandBus(registry=registry)
# Dispatch a command and receive its result
result: OrderDTO = await bus.dispatch(CreateOrderCommand(
customer_id=customer.id,
product_ids=[product.id],
shipping_address="123 Main St",
))
InMemoryQueryBus
from hexcore.application.cqrs.in_memory_buses import InMemoryQueryBus
query_bus = InMemoryQueryBus(registry=registry)
order: OrderDTO = await query_bus.ask(GetOrderByIdQuery(order_id=result.id))
InMemoryEventBus
from hexcore.application.cqrs.in_memory_buses import InMemoryEventBus
from hexcore.domain.events import DomainEvent
event_bus = InMemoryEventBus()
# Subscribe a handler
async def send_confirmation_email(event: OrderCreatedEvent) -> None:
print(f"Sending email for order {event.entity_id}")
event_bus.subscribe(OrderCreatedEvent, send_confirmation_email)
# Publish (all subscribers are called in sequence)
await event_bus.publish(OrderCreatedEvent(entity_id=order_id, entity_data=order))
Message flow diagram
Below is a narrative of the full CQRS flow in a FastAPI endpoint:
HTTP POST /orders
│
▼
FastAPI router
creates CreateOrderCommand
│
▼
InMemoryCommandBus.dispatch(cmd)
│
├─ MiddlewarePipeline runs (e.g. TransactionMiddleware)
│
▼
CreateOrderHandler.handle(cmd)
│
├─ Constructs Order entity
├─ Calls repo.save(order)
├─ Registers OrderCreatedEvent on entity
▼
uow.commit()
│
├─ DB transaction committed
├─ Calls pull_domain_events() on all tracked entities
▼
EventBus.publish(OrderCreatedEvent)
│
├─ send_confirmation_email(event) ← synchronous subscriber
└─ project_order_to_mongo(event) ← synchronous subscriber
(or background if @background_handler)
For reads, the flow is simpler:
HTTP GET /orders/{id}
│
▼
FastAPI router
creates GetOrderByIdQuery
│
▼
InMemoryQueryBus.ask(query)
│
▼
GetOrderByIdHandler.handle(query)
│
▼
Returns OrderDTO directly (no UoW, no events)
Smart background routing
HexCore’s InMemoryCommandBus supports smart routing: if a command class is decorated with @background_command, the bus automatically enqueues it to a task queue instead of executing it locally. Your application code does not need to change.
from hexcore.domain.cqrs.decorators import background_command, background_handler
# This command will always execute in the background worker
@background_command(queue="high_priority")
class GenerateReportCommand(Command):
user_id: UUID
report_type: str
# This event handler will run in the background for every published event
@background_handler(queue="notifications")
async def send_email_on_order_created(event: OrderCreatedEvent) -> None:
# Called by Celery/Procrastinate worker, not the API process
await email_client.send(...)
Wire up a CeleryEnqueuer (or ProcrastinateEnqueuer) to the bus:
from hexcore.infrastructure.task_queues.celery_adapter import CeleryEnqueuer
from hexcore.infrastructure.cqrs.pydantic_serializer import PydanticSerializer
from celery import Celery
app = Celery("myapp", broker="redis://localhost:6379/0")
enqueuer = CeleryEnqueuer(app)
serializer = PydanticSerializer()
command_bus = InMemoryCommandBus(
registry=registry,
enqueuer=enqueuer,
serializer=serializer,
)
The InMemoryQueryBus intentionally does not accept an enqueuer. Queries are always executed synchronously because the caller needs the result before it can continue. Background execution would break the read-response contract.
Error handling
Both buses raise HandlerNotFoundError from hexcore.domain.cqrs.exceptions if no handler is registered for a given command or query type. Catch it at the outermost boundary (e.g. an API exception handler) to return a meaningful HTTP 500:
from hexcore.domain.cqrs.exceptions import HandlerNotFoundError
try:
result = await bus.dispatch(some_command)
except HandlerNotFoundError as exc:
logger.error("No handler registered: %s", exc)
raise HTTPException(status_code=500, detail="Internal configuration error")