Skip to content

CQRS and DDD

Reference

Every option on this page, with types and defaults: configuration reference.

ninja_devx.cqrs adds small, optional pieces for CQRS and DDD over Services and layers. They are conventions with types, not a framework: there is no command bus, no global registry and no automatic wiring.

Concept Building block
Command (write) a frozen dataclass + use_case(...)
Query (read) a frozen dataclass + use_query(...)
Handler a class with __call__(message), resolved from the container
Domain event DomainEvent + EventBus (delivered after commit)
Unit of work UnitOfWork (several repositories, one transaction)
Marker Command/Query (optional base classes, both derive from Message)

Commands and queries

use_case binds a write operation to a handler; use_query is the read counterpart. Both map the validated request to a message and resolve the handler from the controller's container. See Use cases for the full mechanism.

from dataclasses import dataclass

from ninja import FilterSchema
from ninja_devx import Controller, Inject, get, post, use_case, use_query
from ninja_devx.cqrs import Command, Query
from ninja_devx.layers import Repository


@dataclass(frozen=True, slots=True)
class PlaceOrder(Command):
    product_id: int
    quantity: int


@dataclass(frozen=True, slots=True)
class OrderStats(Query):
    prefix: str


class PlaceOrderHandler:
    def __init__(self, products: Repository[Product], orders: Repository[Order]) -> None:
        self.products, self.orders = products, orders

    def __call__(self, command: PlaceOrder) -> Order: ...


class OrderStatsHandler:
    def __call__(self, query: OrderStats) -> dict[str, int]: ...


class StatsFilters(FilterSchema):
    prefix: str = ""


def to_stats(filters: StatsFilters) -> OrderStats:
    return OrderStats(prefix=filters.prefix)


class OrderController(Controller):
    place = use_case(post("/", response={201: OrderOut}), PlaceOrderHandler, command=to_place_order, status=201)
    stats = use_query(get("/stats", response=dict[str, int]), OrderStatsHandler, query=to_stats)

A GET payload becomes query parameters (Annotated[Schema, Query()]), so ?prefix=x reaches the handler. Handlers are resolved per request, so their constructor dependencies (Repository, RequestContext, ...) are injected, and an async def __call__ makes the operation async.

Domain events

A DomainEvent records something that happened. Publish it from a use case; handlers run after the transaction commits, through a TaskQueue, so they never observe rolled-back state.

from dataclasses import dataclass

from ninja_devx import EventBus
from ninja_devx.cqrs import DomainEvent
from ninja_devx.layers import OnCommitTaskQueue


@dataclass(frozen=True, slots=True)
class OrderPlaced(DomainEvent):
    order_id: int


# Wiring (once, at startup)
container.singleton(EventBus, EventBus(OnCommitTaskQueue()))
container.resolve(EventBus).subscribe(OrderPlaced, notify_followers)


# In the handler, after the order is saved
class PlaceOrderHandler:
    def __init__(self, orders: Repository[Order], events: EventBus) -> None:
        self.orders, self.events = orders, events

    def __call__(self, command: PlaceOrder) -> Order:
        with self.orders.transaction():
            order = self.orders.add({...})
        self.events.publish(OrderPlaced(order_id=order.pk))
        return order

Use RecordingTaskQueue in tests and assert with capture_commits/captured_commits. For durable, cross-process delivery, run handlers that enqueue django.tasks work, or publish to the transactional outbox instead.

Unit of work

ModelService already wraps each write in repository.transaction(). Use UnitOfWork when a use case combines several repositories or ORM operations that must commit together:

from ninja_devx.cqrs import UnitOfWork

with UnitOfWork():
    stock.change(product, {"stock": product.stock - command.quantity})
    orders.add({"owner": user, "product": product, "quantity": command.quantity})

Pass using="replica" for another database and durable=True when it must be the outermost atomic block. UnitOfWork is synchronous, like transaction.atomic; in an async handler run the block through sync_to_async.

What this is not

Deliberately absent, to match the project's design principles: a command/query bus, a global handler registry, automatic dispatch by name, and an Aggregate base class. Django models are the aggregates; services and use cases are the domain logic. See the recipes for a complete, tested layering.